import { useEffect, useRef, useState } from "react"; import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react"; import { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus } from "../lib/api.ts"; import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts"; import { navigate } from "../lib/route.ts"; import { AdminPanel } from "./AdminPanel.tsx"; import { AccessRequestsPanel } from "./AccessRequests.tsx"; import { ReportsPanel } from "./ReportsPanel.tsx"; import { ModelsPanel } from "./ModelsPanel.tsx"; import { Toasts } from "./Toasts.tsx"; import { pushToast } from "../lib/toasts.ts"; import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx"; import { cn } from "../lib/cn.ts"; import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts"; import { useT } from "../lib/i18n.ts"; type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail"; const VALID_TABS: readonly string[] = ["accounts", "access", "reports", "models", "features", "mail"]; function validTab(v: string | null | undefined): Tab { return v !== null && v !== undefined && (VALID_TABS as readonly string[]).includes(v) ? (v as Tab) : "accounts"; } function applyTheme(theme: string) { const root = document.documentElement; const dark = resolveThemeDark(theme); root.classList.toggle("dark", dark); root.style.colorScheme = dark ? "dark" : "light"; } export function AdminCenter({ initialTab }: { initialTab?: string | null }) { const { t } = useT(); const [allowed, setAllowed] = useState(null); const [tab, setTab] = useState(() => validTab(initialTab)); const [settings, setSettings] = useState(null); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); const [openAccessCount, setOpenAccessCount] = useState(0); const [openReportCount, setOpenReportCount] = useState(0); // Deep links (/admin, /admin/models, …): unknown sections bounce to /admin. useEffect(() => { if (initialTab !== null && initialTab !== undefined && !VALID_TABS.includes(initialTab)) { navigate("/admin", true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function selectTab(id: Tab): void { setTab(id); navigate(id === "accounts" ? "/admin" : `/admin/${id}`); } // Full badge resync (HTTP) — only for mount, reconnect gaps and rare // actions with no WS event (reporter shadow-ban). Hot path is incremental. const resyncBadgesRef = useRef<() => void>(() => {}); const TABS: { id: Tab; label: string; icon: typeof Users }[] = [ { id: "accounts", label: t("center.tabAccounts"), icon: Users }, { id: "access", label: t("center.tabAccess"), icon: Inbox }, { id: "reports", label: t("center.tabReports"), icon: Flag }, { id: "models", label: t("center.tabModels"), icon: Boxes }, { id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal }, { id: "mail", label: t("center.tabMail"), icon: Mail }, ]; useEffect(() => { let timer: number | undefined; api.verify() .then((r) => { if (!r.user.isAdmin) { setAllowed(false); return; } setAllowed(true); applyTheme(r.user.theme); if (r.user.theme === "sunset") { const schedule = (): void => { timer = window.setTimeout(() => { applyTheme("sunset"); schedule(); }, msUntilNextSolarSwitch()); }; schedule(); } api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed"))); }) .catch(() => setAllowed(false)); return () => window.clearTimeout(timer); }, []); // Live badges on the Access/Reports tabs: the admin firehose pushes full // payloads, so the open counts update incrementally with zero HTTP. // Resync on (re)connect covers events missed while offline. useEffect(() => { if (!allowed) return; const accessById = new Map(); const reportById = new Map(); function recount() { let access = 0; for (const s of accessById.values()) if (s === "pending" || s === "reviewing") access++; let reports = 0; for (const r of reportById.values()) { if ((r.status === "open" || r.status === "reviewing") && r.shadowbanned !== 1) reports++; } setOpenAccessCount(access); setOpenReportCount(reports); } async function resync() { try { const res = await api.adminAccessList(); accessById.clear(); for (const r of res.requests) accessById.set(r.id, r.status); } catch { // Transient failure: keep last counts. } try { const res = await api.adminReportList(); reportById.clear(); for (const r of res.reports) { reportById.set(r.id, { status: r.status, shadowbanned: r.reporter_shadowbanned }); } } catch { // Transient failure: keep last counts. } recount(); } resyncBadgesRef.current = resync; resync(); const token = getToken(); if (!token) return; return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), { onEvent: (evt) => { if (evt.type === "pong" || evt.type === "access_message") return; if (evt.type === "access_created") { accessById.set(evt.request_id, evt.request.status); recount(); pushToast(t("live.newRequest", { user: evt.request.username }), { icon: "inbox" }); } else if (evt.type === "access_status") { accessById.set(evt.request_id, evt.request.status); recount(); } else if (evt.type === "report_created") { reportById.set(evt.report.id, { status: evt.report.status, shadowbanned: evt.report.reporter_shadowbanned }); recount(); pushToast(t("live.newReport", { reason: evt.report.reason }), { icon: "mail" }); } else if (evt.type === "report_status") { const prev = reportById.get(evt.report.id); reportById.set(evt.report.id, { status: evt.report.status, shadowbanned: prev?.shadowbanned ?? evt.report.reporter_shadowbanned, }); recount(); } }, onSync: () => { resync(); }, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [allowed]); async function toggle(patch: AdminSettingsPatch) { setSaving(true); setError(""); try { const res = await api.adminPatchSettings(patch); setSettings(res.settings); } catch (e) { setError(e instanceof Error ? e.message : t("common.saveFailed")); } finally { setSaving(false); } } if (allowed === null) { return ; } if (!allowed) { return (

{t("center.locked")}

{t("center.lockedSub")}

{t("common.backToLogin")}
); } return (

{t("center.title")}

{TABS.map((t) => ( ))}
{error &&

{error}

} {tab === "accounts" && (
{}} />
)} {tab === "access" && (
)} {tab === "reports" && (
resyncBadgesRef.current()} />
)} {tab === "models" && (
)} {tab === "features" && (

{t("center.regTitle")}

{t("center.regDesc")}

toggle({ registrationEnabled: v })} />

{t("center.accTitle")}

{settings?.registrationEnabled ? t("center.accDescOn") : t("center.accDescOff")}

toggle({ accessRequestEnabled: v })} />

{t("center.ocrTitle")}

{t("center.ocrDesc")}

toggle({ ocrEnabled: v })} />

{t("center.reportsTitle")}

{t("center.reportsDesc")}

toggle({ reportsEnabled: v })} />

{t("center.usernameChangeTitle")}

{t("center.usernameChangeDesc")}

toggle({ usernameChangeEnabled: v })} />
{saving &&

{t("common.saving")}

} {!settings && !error &&

{t("common.loading")}

}
)} {tab === "mail" && }
); } type SmtpInfo = { enabled: boolean; host: string; port: number; secure: boolean; user: string; pass: string; hasPass: boolean; from: string; appUrl: string; }; function MailPanel() { const { t } = useT(); const [smtp, setSmtp] = useState(null); const [error, setError] = useState(""); const [showPass, setShowPass] = useState(false); const [to, setTo] = useState(""); const [verifying, setVerifying] = useState(false); const [sending, setSending] = useState(false); const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null); const [recMax, setRecMax] = useState("5"); const [recWindow, setRecWindow] = useState("60"); const [recSaving, setRecSaving] = useState(false); const [recMsg, setRecMsg] = useState<{ ok: boolean; text: string } | null>(null); useEffect(() => { api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed"))); api.adminGetSettings().then((r) => { setRecMax(String(r.settings.recoveryLimitMax)); setRecWindow(String(r.settings.recoveryLimitWindowMin)); }).catch(() => {}); }, []); async function verify() { setVerifying(true); setResult(null); try { await api.adminMailVerify(); setResult({ ok: true, text: t("center.connOk") }); } catch (e) { setResult({ ok: false, text: e instanceof Error ? e.message : t("center.verifyFailed") }); } finally { setVerifying(false); } } async function sendTest() { setSending(true); setResult(null); try { const r = await api.adminMailTest(to.trim()); setResult({ ok: true, text: t("center.testSent", { to: to.trim(), tail: r.messageId ? ` (${r.messageId})` : "" }) }); } catch (e) { setResult({ ok: false, text: e instanceof Error ? e.message : t("center.mailSendFailed") }); } finally { setSending(false); } } function Row({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } async function saveRecoveryLimits() { const max = Number(recMax); const win = Number(recWindow); if (!Number.isInteger(max) || max < 1 || max > 100 || !Number.isInteger(win) || win < 1 || win > 1440) { setRecMsg({ ok: false, text: t("rec.invalid") }); return; } setRecSaving(true); setRecMsg(null); try { const res = await api.adminPatchSettings({ recoveryLimitMax: max, recoveryLimitWindowMin: win }); setRecMax(String(res.settings.recoveryLimitMax)); setRecWindow(String(res.settings.recoveryLimitWindowMin)); setRecMsg({ ok: true, text: t("rec.saved") }); } catch (e) { setRecMsg({ ok: false, text: e instanceof Error ? e.message : t("common.saveFailed") }); } finally { setRecSaving(false); } } return (

{t("center.mailTitle")}

{smtp == null ? t("center.mailChecking") : smtp.enabled ? t("center.mailFrom", { from: smtp.from }) : t("center.mailOff")}

{smtp?.enabled ? t("center.on") : t("center.off")}
{error &&

{error}

} {!smtp && !error &&

{t("common.loading")}

} {smtp && (
{smtp.host || {t("center.notSet")}} {smtp.port} · {smtp.secure ? t("center.tlsImplicit") : t("center.tlsStarttls")} {smtp.user || {t("center.noAuth")}} {!smtp.hasPass ? ( {t("center.notSet")} ) : showPass ? ( {smtp.pass} ) : ( •••••••••• )} {smtp.hasPass && ( <> {showPass && } )} {smtp.from} {smtp.appUrl}
)}

{t("center.credsNote")}

{t("center.mailTestTitle")}

{t("center.mailTestDesc")}

setTo(e.target.value)} className="flex-1" />
{result && (

{result.text}

)}

{t("rec.title")}

{t("rec.desc")}

setRecMax(e.target.value)} disabled={recSaving} />
setRecWindow(e.target.value)} disabled={recSaving} />
{recMsg && (

{recMsg.text}

)}
); }