Files
netrunner-backend/frontend/src/StaffPage.tsx
T
nineap 583682f330 Админка: страницы Тикеты + Мониторинг, роль support в навигации
TicketsPage.tsx — список open/closed тикетов + тред сообщений + ответ +
закрытие (owner/moderator/support). ObservabilityPage.tsx — вкладки
дашбордов Grafana через iframe за auth-гейтом Caddy, URL берётся из
GET /api/v1/config (grafana_url), страница просто не показывает
дашборды, пока GRAFANA_PUBLIC_URL не задан на сервере. StaffPage.tsx —
форма создания support-аккаунта (только Owner).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 23:20:59 +07:00

278 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useOutletContext } from "react-router-dom";
import { UserPlus, RefreshCw } from "lucide-react";
import {
createModerator,
createPartner,
createSupport,
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 [supportUsername, setSupportUsername] = useState("");
const [supportPassword, setSupportPassword] = useState("");
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 handleCreateSupport = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await createSupport(supportUsername, supportPassword);
setSupportUsername("");
setSupportPassword("");
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>
)}
{role.role === "owner" && (
<form
onSubmit={handleCreateSupport}
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={supportUsername}
onChange={(e) => setSupportUsername(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={supportPassword}
onChange={(e) => setSupportPassword(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"
/>
<p className="text-[10px] text-muted-foreground">
Видит только раздел "Тикеты" доступа к нодам/staff/выводам нет.
</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>
)}
<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>
);
}