Ролевая админка (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,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { Loader2, Server, Users, Wallet, DollarSign, LogOut } from "lucide-react";
|
||||
import { fetchMyRole, logout, type StaffRole } from "./api";
|
||||
|
||||
const STAFF_ROLES = ["owner", "moderator", "partner"];
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const [role, setRole] = useState<StaffRole | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyRole()
|
||||
.then((r) => {
|
||||
if (!STAFF_ROLES.includes(r.role)) {
|
||||
navigate("/admin/login");
|
||||
return;
|
||||
}
|
||||
setRole(r);
|
||||
})
|
||||
.catch(() => navigate("/admin/login"))
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#05050A]">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!role) return null;
|
||||
|
||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-2 px-4 py-2.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
|
||||
}`;
|
||||
|
||||
const canSeeNodes = role.role === "owner" || (role.role === "moderator" && role.can_manage_nodes);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#05050A] font-mono">
|
||||
<nav className="border-b border-white/10 px-6 py-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canSeeNodes && (
|
||||
<NavLink to="/admin" end className={navClass}>
|
||||
<Server className="w-4 h-4" /> Ноды
|
||||
</NavLink>
|
||||
)}
|
||||
{role.role === "owner" && (
|
||||
<NavLink to="/admin/staff" className={navClass}>
|
||||
<Users className="w-4 h-4" /> Staff
|
||||
</NavLink>
|
||||
)}
|
||||
{role.role === "moderator" && (
|
||||
<NavLink to="/admin/staff" className={navClass}>
|
||||
<Users className="w-4 h-4" /> Партнёры
|
||||
</NavLink>
|
||||
)}
|
||||
{(role.role === "owner" || role.role === "moderator") && (
|
||||
<NavLink to="/admin/withdrawals" className={navClass}>
|
||||
<Wallet className="w-4 h-4" /> Выводы
|
||||
</NavLink>
|
||||
)}
|
||||
{role.role === "partner" && (
|
||||
<NavLink to="/admin/earnings" className={navClass}>
|
||||
<DollarSign className="w-4 h-4" /> Доходы
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-widest text-muted-foreground hover:bg-white/5 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" /> Выход
|
||||
</button>
|
||||
</nav>
|
||||
<Outlet context={role} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Shield, Loader2 } from "lucide-react";
|
||||
import { adminLogin } from "./api";
|
||||
|
||||
export default function AdminLogin() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
await adminLogin(username, password);
|
||||
navigate("/admin");
|
||||
} catch {
|
||||
setError("Неверный логин или пароль.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#05050A] font-mono px-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm bg-white/[0.02] border border-white/10 rounded-2xl p-8 space-y-6"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<Shield className="w-10 h-10 text-primary" />
|
||||
<h1 className="text-lg font-black tracking-widest text-primary uppercase">
|
||||
Admin Access
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-widest text-xs py-3 rounded-lg transition-all flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Войти"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
-1
@@ -7,6 +7,11 @@ const Profile = lazy(() => import("./Profile"));
|
||||
const HackGame = lazy(() => import("./HackGame"));
|
||||
const AdminDashboard = lazy(() => import("./AdminDashboard"));
|
||||
const AuthClient = lazy(() => import("./Auth"));
|
||||
const AdminLogin = lazy(() => import("./AdminLogin"));
|
||||
const AdminLayout = lazy(() => import("./AdminLayout"));
|
||||
const StaffPage = lazy(() => import("./StaffPage"));
|
||||
const WithdrawalsPage = lazy(() => import("./WithdrawalsPage"));
|
||||
const EarningsPage = lazy(() => import("./EarningsPage"));
|
||||
|
||||
const DICT = {
|
||||
en: { loading: "Loading protocols..." },
|
||||
@@ -83,7 +88,13 @@ export default function App() {
|
||||
<Route path="/" element={<AuthClient />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/hack" element={<HackGame />} />
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="staff" element={<StaffPage />} />
|
||||
<Route path="withdrawals" element={<WithdrawalsPage />} />
|
||||
<Route path="earnings" element={<EarningsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthHandler>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, DollarSign, Send, Copy, Check } from "lucide-react";
|
||||
import {
|
||||
fetchMyEarnings,
|
||||
requestWithdrawal,
|
||||
getPublicConfig,
|
||||
type MyEarnings,
|
||||
} from "./api";
|
||||
|
||||
export default function EarningsPage() {
|
||||
const [data, setData] = useState<MyEarnings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [botUsername, setBotUsername] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [currency, setCurrency] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const earnings = await fetchMyEarnings();
|
||||
setData(earnings);
|
||||
if (!currency && earnings.earnings.length > 0) {
|
||||
setCurrency(earnings.earnings[0].currency);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
getPublicConfig().then((cfg) => setBotUsername(cfg.bot_username));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const referralLink = data?.partner_code
|
||||
? `https://t.me/${botUsername}?start=${data.partner_code}`
|
||||
: "";
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!referralLink) return;
|
||||
await navigator.clipboard.writeText(referralLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
await requestWithdrawal(currency, Math.round(Number(amount) * 100), note || undefined);
|
||||
setAmount("");
|
||||
setNote("");
|
||||
await load();
|
||||
} catch {
|
||||
setError("Не удалось создать заявку (сумма превышает доступный остаток?).");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 sm:p-10 max-w-3xl mx-auto space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||
Мои доходы
|
||||
</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>
|
||||
|
||||
{referralLink && (
|
||||
<div className="bg-black/40 border border-white/10 rounded-2xl p-5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest mb-1">
|
||||
Ваша реферальная ссылка
|
||||
</p>
|
||||
<p className="font-mono text-sm truncate">{referralLink}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors shrink-0"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-secondary" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{(data?.earnings ?? []).map((e) => (
|
||||
<div
|
||||
key={e.currency}
|
||||
className="bg-black/60 border border-white/5 rounded-xl p-5 space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span className="font-black text-lg">{e.currency}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Заработано: <span className="text-foreground font-bold">{(e.total_earned / 100).toFixed(2)}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выведено: {(e.total_withdrawn / 100).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
В обработке: {(e.pending_withdrawal / 100).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm font-bold text-secondary">
|
||||
Доступно: {(e.available / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{(data?.earnings ?? []).length === 0 && !loading && (
|
||||
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest sm:col-span-2">
|
||||
Пока нет заработка
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
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">
|
||||
<Send className="w-4 h-4" /> Заявка на вывод
|
||||
</h2>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
required
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
className="bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Валюта
|
||||
</option>
|
||||
{(data?.earnings ?? []).map((e) => (
|
||||
<option key={e.currency} value={e.currency}>
|
||||
{e.currency}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="Сумма"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Комментарий (реквизиты и т.п., необязательно)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(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"
|
||||
/>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
<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>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
История заявок
|
||||
</h2>
|
||||
{(data?.withdrawals ?? []).map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className="bg-black/60 border border-white/5 rounded-xl p-4 flex items-center justify-between"
|
||||
>
|
||||
<span className="font-mono text-sm">
|
||||
{(w.amount / 100).toFixed(2)} {w.currency}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground">
|
||||
{w.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, Check, X, Banknote } from "lucide-react";
|
||||
import { fetchWithdrawals, updateWithdrawal, type WithdrawalRequest } from "./api";
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: "text-yellow-500",
|
||||
approved: "text-secondary",
|
||||
paid: "text-green-500",
|
||||
rejected: "text-red-500",
|
||||
};
|
||||
|
||||
export default function WithdrawalsPage() {
|
||||
const [list, setList] = useState<WithdrawalRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setList(await fetchWithdrawals());
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleUpdate = async (id: string, status: "approved" | "rejected" | "paid") => {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await updateWithdrawal(id, status);
|
||||
await load();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 sm:p-10 max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||||
Заявки на вывод
|
||||
</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>
|
||||
|
||||
<div className="space-y-3">
|
||||
{list.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className="bg-black/60 border border-white/5 rounded-xl p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Banknote className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-bold font-mono">
|
||||
{(w.amount / 100).toFixed(2)} {w.currency}
|
||||
</span>
|
||||
<span className={`text-[10px] uppercase tracking-widest font-bold ${STATUS_COLOR[w.status]}`}>
|
||||
{w.status}
|
||||
</span>
|
||||
</div>
|
||||
{w.note && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{w.note}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground/60 font-mono mt-1">
|
||||
{new Date(w.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{w.status === "pending" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={busyId === w.id}
|
||||
onClick={() => handleUpdate(w.id, "approved")}
|
||||
className="p-2 bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/20 rounded-lg transition-colors disabled:opacity-40"
|
||||
title="Одобрить"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
disabled={busyId === w.id}
|
||||
onClick={() => handleUpdate(w.id, "rejected")}
|
||||
className="p-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 rounded-lg transition-colors disabled:opacity-40"
|
||||
title="Отклонить"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{w.status === "approved" && (
|
||||
<button
|
||||
disabled={busyId === w.id}
|
||||
onClick={() => handleUpdate(w.id, "paid")}
|
||||
className="px-3 py-2 bg-green-500/10 hover:bg-green-500/20 text-green-500 border border-green-500/20 rounded-lg text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40"
|
||||
>
|
||||
Отметить выплаченным
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{list.length === 0 && !loading && (
|
||||
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
|
||||
Заявок нет
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+144
-2
@@ -71,8 +71,12 @@ export async function apiFetch(
|
||||
|
||||
const refreshed = await refreshSession();
|
||||
if (!refreshed) {
|
||||
if (window.location.pathname !== "/") {
|
||||
window.location.href = "/";
|
||||
// Админка (Owner/Moderator/Partner) логинится отдельно от обычных
|
||||
// Ghost/Telegram-юзеров — редирект на "/" был бы неверным экраном для неё.
|
||||
const isAdminPath = window.location.pathname.startsWith("/admin");
|
||||
const loginPath = isAdminPath ? "/admin/login" : "/";
|
||||
if (window.location.pathname !== loginPath) {
|
||||
window.location.href = loginPath;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -161,6 +165,144 @@ export const exchangeBootstrapToken = async (token: string): Promise<boolean> =>
|
||||
return res.ok;
|
||||
};
|
||||
|
||||
// --- Ролевая админка (Owner/Moderator/Partner) ---
|
||||
|
||||
export const adminLogin = async (username: string, password: string) => {
|
||||
const res = await fetch(`${API_BASE}/auth/admin/login`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return parseOrThrow(res, "Login failed");
|
||||
};
|
||||
|
||||
export interface StaffRole {
|
||||
role: "owner" | "moderator" | "partner" | "user" | "admin";
|
||||
can_manage_nodes: boolean;
|
||||
username: string | null;
|
||||
partner_code: string | null;
|
||||
commission_percent: string | null;
|
||||
}
|
||||
|
||||
export const fetchMyRole = async (): Promise<StaffRole> => {
|
||||
const res = await apiFetch("/admin/staff/me");
|
||||
return parseOrThrow(res, "Failed to fetch role");
|
||||
};
|
||||
|
||||
export const createModerator = async (
|
||||
username: string,
|
||||
password: string,
|
||||
canManageNodes: boolean,
|
||||
) => {
|
||||
const res = await apiFetch("/admin/staff/moderators", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
can_manage_nodes: canManageNodes,
|
||||
}),
|
||||
});
|
||||
return parseOrThrow(res, "Failed to create moderator");
|
||||
};
|
||||
|
||||
export const createPartner = async (
|
||||
username: string,
|
||||
password: string,
|
||||
commissionPercent: number,
|
||||
) => {
|
||||
const res = await apiFetch("/admin/staff/partners", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
commission_percent: commissionPercent,
|
||||
}),
|
||||
});
|
||||
return parseOrThrow(res, "Failed to create partner");
|
||||
};
|
||||
|
||||
export interface StaffUser {
|
||||
id: string;
|
||||
username: string | null;
|
||||
role: string;
|
||||
can_manage_nodes: boolean;
|
||||
commission_percent: string | null;
|
||||
partner_code: string | null;
|
||||
}
|
||||
|
||||
export const fetchStaffUsers = async (): Promise<StaffUser[]> => {
|
||||
const res = await apiFetch("/admin/staff/staff-users");
|
||||
return parseOrThrow(res, "Failed to fetch staff users");
|
||||
};
|
||||
|
||||
export const setNodePermission = async (userId: string, canManageNodes: boolean) => {
|
||||
const res = await apiFetch(`/admin/staff/users/${userId}/permissions`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ can_manage_nodes: canManageNodes }),
|
||||
});
|
||||
return parseOrThrow(res, "Failed to update permissions");
|
||||
};
|
||||
|
||||
export interface WithdrawalRequest {
|
||||
id: string;
|
||||
partner_id: string;
|
||||
currency: string;
|
||||
amount: number;
|
||||
status: "pending" | "approved" | "rejected" | "paid";
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
processed_at: string | null;
|
||||
processed_by: string | null;
|
||||
}
|
||||
|
||||
export const fetchWithdrawals = async (): Promise<WithdrawalRequest[]> => {
|
||||
const res = await apiFetch("/admin/staff/withdrawals");
|
||||
return parseOrThrow(res, "Failed to fetch withdrawals");
|
||||
};
|
||||
|
||||
export const updateWithdrawal = async (
|
||||
id: string,
|
||||
status: "approved" | "rejected" | "paid",
|
||||
) => {
|
||||
const res = await apiFetch(`/admin/staff/withdrawals/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
return parseOrThrow(res, "Failed to update withdrawal");
|
||||
};
|
||||
|
||||
export interface PartnerEarnings {
|
||||
currency: string;
|
||||
total_earned: number;
|
||||
total_withdrawn: number;
|
||||
pending_withdrawal: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
export interface MyEarnings {
|
||||
earnings: PartnerEarnings[];
|
||||
withdrawals: WithdrawalRequest[];
|
||||
partner_code: string | null;
|
||||
}
|
||||
|
||||
export const fetchMyEarnings = async (): Promise<MyEarnings> => {
|
||||
const res = await apiFetch("/admin/partners/earnings");
|
||||
return parseOrThrow(res, "Failed to fetch earnings");
|
||||
};
|
||||
|
||||
export const requestWithdrawal = async (
|
||||
currency: string,
|
||||
amount: number,
|
||||
note?: string,
|
||||
) => {
|
||||
const res = await apiFetch("/admin/partners/withdrawals", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currency, amount, note }),
|
||||
});
|
||||
return parseOrThrow(res, "Failed to submit withdrawal request");
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user