import { useEffect, useRef, useState } from "react"; import { CheckCircle2, Clock, Eye, EyeOff, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react"; import { api, getToken, type Report, type ReportStatus } from "../lib/api.ts"; import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts"; import { Button, ConfirmDialog, Field, Input, Spinner } from "./ui.tsx"; import { useT, type StringKey } from "../lib/i18n.ts"; import { cn } from "../lib/cn.ts"; const STATUS_STYLE: Record = { open: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" }, reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" }, resolved: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" }, dismissed: { cls: "bg-red-500/10 text-red-600 dark:text-red-400" }, }; type Filter = "all" | ReportStatus; const FILTERS: Filter[] = ["all", "open", "reviewing", "resolved", "dismissed"]; /** Admin triage for user-flagged AI responses: review / resolve / dismiss. */ export function ReportsPanel({ onShadowbanChanged }: { onShadowbanChanged?: () => void }) { const [reports, setReports] = useState([]); const [filter, setFilter] = useState("open"); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const { t } = useT(); const listBusy = useRef(false); async function refresh(select?: string) { if (listBusy.current) return; listBusy.current = true; try { const res = await api.adminReportList(); setReports(res.reports); if (select) setSelectedId(select); } catch (err) { setError(err instanceof Error ? err.message : t("admin.loadFailed")); } finally { listBusy.current = false; setLoading(false); } } useEffect(() => { refresh(); // Live list: the admin firehose pushes full report payloads, so rows // update incrementally with zero HTTP (shares its socket with the // AdminCenter badge via the pool). Full resync only on (re)connect. const token = getToken(); if (!token) return; return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), { onEvent: (evt) => { if (evt.type === "report_created") { setReports((prev) => (prev.some((r) => r.id === evt.report.id) ? prev : [evt.report, ...prev])); } else if (evt.type === "report_status") { setReports((prev) => prev.map((r) => (r.id === evt.report.id ? evt.report : r))); } // Access events: the AccessRequestsPanel owns them. }, onSync: () => { refresh(); }, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const visible = reports.filter((r) => filter === "all" || r.status === filter); const openCount = reports.filter((r) => r.status === "open").length; return (
{FILTERS.map((f) => { const n = f === "all" ? reports.length : reports.filter((r) => r.status === f).length; return ( ); })}
{error &&

{error}

} {loading &&

{t("common.loading")}

} {!loading && visible.length === 0 && (

{openCount > 0 ? t("reports.emptyFiltered") : t("reports.empty")}

)}
    {visible.map((r) => (
  • {selectedId === r.id && ( setReports((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))} onBanned={(reporterId, shadowbanned) => { setReports((prev) => prev.map((x) => (x.reporter_id === reporterId ? { ...x, reporter_shadowbanned: shadowbanned ? 1 : 0 } : x)), ); onShadowbanChanged?.(); }} /> )}
  • ))}
); } function ReportDetail({ report, onPatched, onBanned }: { report: Report; onPatched: (r: Report) => void; onBanned: (reporterId: string, shadowbanned: boolean) => void }) { const [note, setNote] = useState(report.admin_note); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [confirmBan, setConfirmBan] = useState(null); const { t } = useT(); const closed = report.status === "resolved" || report.status === "dismissed"; const isBanned = report.reporter_shadowbanned === 1; useEffect(() => { setNote(report.admin_note); }, [report.id, report.admin_note]); async function setStatus(status: ReportStatus) { setBusy(true); setError(""); try { // The report_status broadcast echoes the same payload to every admin — // applying the response directly keeps this view instant and idempotent. const res = await api.adminReportPatch(report.id, { status, adminNote: note.trim() }); onPatched(res.report); } catch (err) { setError(err instanceof Error ? err.message : t("reports.updateFailed")); } finally { setBusy(false); } } async function toggleBan(shadowbanned: boolean) { setBusy(true); setError(""); try { await api.adminShadowban(report.reporter_id, shadowbanned); // Shadow-ban has no WS event (per-user, not per-report): patch every // row of this reporter locally and resync the badges over HTTP. onBanned(report.reporter_id, shadowbanned); } catch (err) { setError(err instanceof Error ? err.message : t("reports.updateFailed")); } finally { setBusy(false); } } return (
{report.prompt && (

{t("reports.prompt")}

{report.prompt}
)}

{t("reports.response")}

{report.content || {t("reports.noContent")}}
{t("reports.model")}: {report.model || "—"} {t("reports.reporter")}: @{report.reporter_name} {report.conversation_id && ( {t("reports.conversation")}: {report.conversation_id.slice(0, 8)}… )} {report.details && ( {t("reports.detailsGiven")}: {report.details} )}
{isBanned && {t("reports.unreliable")}}
setConfirmBan(null)} onConfirm={() => { const v = confirmBan; setConfirmBan(null); if (v !== null) void toggleBan(v); }} title={t(confirmBan === false ? "reports.unshadowbanTitle" : "reports.shadowbanTitle")} message={t(confirmBan === false ? "reports.unshadowbanMsg" : "reports.shadowbanMsg", { user: report.reporter_name })} confirmLabel={t(confirmBan === false ? "reports.unshadowban" : "reports.shadowban")} /> {!closed ? (
setNote(e.target.value)} placeholder={t("reports.notePh")} maxLength={1000} />
{report.status === "open" && ( )}
) : (

{t("reports.closedNote", { status: t(`reports.status.${report.status}` as StringKey).toLowerCase() })} {report.admin_note && <> {t("reports.noteWord")}: {report.admin_note}}

)} {error &&

{error}

}
); }