Ролевая админка (Owner/Moderator/Partner) + оплата через @CryptoBot
Ролевая система вместо привязки к Telegram ---------------------------------------- Раньше единственный способ получить админ-доступ — admin_guard, пускавший по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на Owner/Moderator/Partner: - migrations/0005_roles_and_partners.sql — username/password_hash на users (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/ tg_id), can_manage_nodes, commission_percent, partner_code, referred_by_partner_id; новые таблицы partner_commissions и withdrawal_requests. - auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/ node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs. - auth/service.rs — hash_password/login_staff со случайной Argon2-солью на каждого юзера (не общий фиксированный ARGON_SALT, который годится для высокоэнтропийных seed-логинов, но не для человеческих паролей). - POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем, переиспользует существующий issue_session(). - --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) — вместо ручного SQL для создания первого владельца с VPS-консоли. - src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения can_manage_nodes, списка заявок на вывод и их approve/reject/paid. - Комиссия партнёра начисляется в BillingService::confirm_payment отдельным шагом после существующей рефералки, пересчитывается через get_plan_price(invoice.plan_name, invoice.currency) — НЕ через invoice.amount напрямую, потому что у провайдеров разная внутренняя единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую). - bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа). - frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage поверх существующего Vite-админ-фронта (без отдельного проекта). Оплата через @CryptoBot ------------------------ - modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay API), плюс попутно исправлены два реальных бага в существующем коде: confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко всем провайдерам вместо родной валюты инвойса. - bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { UserPlus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
createModerator,
|
||||
createPartner,
|
||||
fetchStaffUsers,
|
||||
setNodePermission,
|
||||
type StaffRole,
|
||||
type StaffUser,
|
||||
} from "./api";
|
||||
|
||||
export default function StaffPage() {
|
||||
const role = useOutletContext<StaffRole>();
|
||||
const [staff, setStaff] = useState<StaffUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [modUsername, setModUsername] = useState("");
|
||||
const [modPassword, setModPassword] = useState("");
|
||||
const [modCanManageNodes, setModCanManageNodes] = useState(false);
|
||||
|
||||
const [partnerUsername, setPartnerUsername] = useState("");
|
||||
const [partnerPassword, setPartnerPassword] = useState("");
|
||||
const [partnerPercent, setPartnerPercent] = useState("10");
|
||||
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setStaff(await fetchStaffUsers());
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleCreateModerator = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
await createModerator(modUsername, modPassword, modCanManageNodes);
|
||||
setModUsername("");
|
||||
setModPassword("");
|
||||
setModCanManageNodes(false);
|
||||
await load();
|
||||
} catch {
|
||||
setError("Не удалось создать модератора (логин уже занят?).");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePartner = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
await createPartner(partnerUsername, partnerPassword, Number(partnerPercent));
|
||||
setPartnerUsername("");
|
||||
setPartnerPassword("");
|
||||
setPartnerPercent("10");
|
||||
await load();
|
||||
} catch {
|
||||
setError("Не удалось создать партнёра (логин уже занят?).");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePermission = async (userId: string, current: boolean) => {
|
||||
try {
|
||||
await setNodePermission(userId, !current);
|
||||
await load();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 sm:p-10 max-w-5xl mx-auto space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||
Staff
|
||||
</h1>
|
||||
<button
|
||||
onClick={load}
|
||||
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{role.role === "owner" && (
|
||||
<form
|
||||
onSubmit={handleCreateModerator}
|
||||
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||||
>
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
|
||||
<UserPlus className="w-4 h-4" /> Новый модератор
|
||||
</h2>
|
||||
<input
|
||||
required
|
||||
placeholder="Логин"
|
||||
value={modUsername}
|
||||
onChange={(e) => setModUsername(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={modPassword}
|
||||
onChange={(e) => setModPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modCanManageNodes}
|
||||
onChange={(e) => setModCanManageNodes(e.target.checked)}
|
||||
className="accent-secondary"
|
||||
/>
|
||||
Право управлять нодами
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleCreatePartner}
|
||||
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||||
>
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-primary flex items-center gap-2">
|
||||
<UserPlus className="w-4 h-4" /> Новый партнёр
|
||||
</h2>
|
||||
<input
|
||||
required
|
||||
placeholder="Логин"
|
||||
value={partnerUsername}
|
||||
onChange={(e) => setPartnerUsername(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={partnerPassword}
|
||||
onChange={(e) => setPartnerPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={partnerPercent}
|
||||
onChange={(e) => setPartnerPercent(e.target.value)}
|
||||
className="w-24 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">% с продаж</span>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
Список staff
|
||||
</h2>
|
||||
{staff.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="bg-black/60 border border-white/5 rounded-xl p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm">{u.username}</span>
|
||||
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded uppercase text-muted-foreground">
|
||||
{u.role}
|
||||
</span>
|
||||
</div>
|
||||
{u.role === "partner" && (
|
||||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
||||
{u.commission_percent}% · код: {u.partner_code}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{role.role === "owner" && u.role === "moderator" && (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={u.can_manage_nodes}
|
||||
onChange={() => handleTogglePermission(u.id, u.can_manage_nodes)}
|
||||
className="accent-secondary"
|
||||
/>
|
||||
Ноды
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{staff.length === 0 && !loading && (
|
||||
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
|
||||
Пока никого нет
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user