Ролевая админка (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:
2026-07-08 22:53:02 +07:00
parent c8eea8203e
commit 375b333978
31 changed files with 2607 additions and 115 deletions
+225
View File
@@ -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>
);
}