import { useEffect, useRef, useState } from "react"; import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react"; import { api, getToken, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts"; import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts"; import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx"; import { useT, type StringKey } from "../lib/i18n.ts"; import { cn } from "../lib/cn.ts"; const STATUS_STYLE: Record = { pending: { 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" }, accepted: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" }, refused: { cls: "bg-red-500/10 text-red-600 dark:text-red-400" }, }; type Filter = "all" | AccessStatus; const FILTERS: Filter[] = ["all", "pending", "reviewing", "accepted", "refused"]; /** Admin triage for the access-request wishlist: accept / refuse / review. */ export function AccessRequestsPanel() { const [requests, setRequests] = useState<(AccessRequest & { message_count: number })[]>([]); const [filter, setFilter] = useState("pending"); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const { t } = useT(); async function refresh(select?: string) { try { const res = await api.adminAccessList(); setRequests(res.requests); if (select) setSelectedId(select); } catch (err) { setError(err instanceof Error ? err.message : t("admin.loadFailed")); } finally { setLoading(false); } } useEffect(() => { refresh(); // Live list: the admin firehose pushes every access event (shares its // socket with the AdminCenter badge via the pool). Resync on // (re)connect covers events missed while offline. const token = getToken(); if (!token) return; return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), { onEvent: (evt) => { if (evt.type !== "pong") refresh(); }, onSync: () => { refresh(); }, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const visible = requests.filter((r) => filter === "all" || r.status === filter); const pendingCount = requests.filter((r) => r.status === "pending").length; return (
{FILTERS.map((f) => { const n = f === "all" ? requests.length : requests.filter((r) => r.status === f).length; return ( ); })}
{error &&

{error}

} {loading &&

{t("common.loading")}

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

{pendingCount > 0 ? t("access.emptyFiltered") : t("access.empty")}

)}
    {visible.map((r) => (
  • {selectedId === r.id && refresh(r.id)} />}
  • ))}
); } function TicketDetail({ request, onChanged }: { request: AccessRequest; onChanged: () => void }) { const [messages, setMessages] = useState([]); const [reason, setReason] = useState(request.reason); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const { t } = useT(); const [freshKey, setFreshKey] = useState(null); // Latest callbacks / server state without restarting the poll loop. const onChangedRef = useRef(onChanged); onChangedRef.current = onChanged; const known = useRef({ status: request.status, reason: request.reason }); useEffect(() => { setReason(request.reason); setFreshKey(null); known.current = { status: request.status, reason: request.reason }; // Live thread: requester replies arrive instantly, and a status change // made elsewhere syncs the list (badges, filters). Resync on // (re)connect covers events missed while offline. async function fetchOnce() { try { const res = await api.adminAccessGet(request.id); setMessages(res.messages); if ( res.request.status !== known.current.status || res.request.reason !== known.current.reason ) { known.current = { status: res.request.status, reason: res.request.reason }; onChangedRef.current(); } } catch { // Transient failure: keep last state. } } fetchOnce(); return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), { onEvent: (evt) => { if (evt.type === "pong" || evt.request_id !== request.id) return; if (evt.type === "access_message") { const msg = evt.message; setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg])); onChangedRef.current(); } else if (evt.type === "access_status") { known.current = { status: evt.request.status, reason: evt.request.reason }; onChangedRef.current(); } }, onSync: () => { fetchOnce(); }, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [request.id]); const closed = request.status === "accepted" || request.status === "refused"; async function setStatus(status: AccessStatus) { setBusy(true); setError(""); try { const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() }); if (res.key) setFreshKey(res.key); known.current = { status: res.request.status, reason: res.request.reason }; onChanged(); } catch (err) { setError(err instanceof Error ? err.message : t("access.updateFailed")); } finally { setBusy(false); } } async function reply(e: React.FormEvent) { e.preventDefault(); const body = draft.trim(); if (!body || busy) return; setBusy(true); setError(""); try { const res = await api.adminAccessReply(request.id, body); setMessages((prev) => [...prev, res.message]); setDraft(""); onChanged(); } catch (err) { setError(err instanceof Error ? err.message : t("access.sendFailed")); } finally { setBusy(false); } } return (
{freshKey && (

{t("access.accountCreated")}

{freshKey}
)}
{messages.map((m) => (

{m.author === "admin" ? t("access.youAdmin") : `@${request.username}`} · {new Date(m.created_at).toLocaleString()}

{m.body}

))}