/* ═══════════════════════════════════════════════════════════════ ADMIN PANEL Superadmin only, guarded by RequireRole on the route and by requireRole("superadmin") on every endpoint it calls. This page assumes neither: it renders whatever the API gives it and shows whatever the API refuses. Three blocks, deliberately boring: System — what's actually running, for when something is off Content — row counts, the cheapest "is the database there" Accounts — roles, access and live sessions The role select is driven by ROLES from lib/roles.ts, so a new rung on the ladder appears here without this file changing. The legend beside it is the same list — four roles is past the point where "Editor" explains itself. Every account write signs that person out, which the server does rather than this page. Two things you can't do here: edit your own row, or take the last active superadmin away. Both are enforced server-side and mirrored in the disabled states, so the reason shows up before the click rather than after it. ═══════════════════════════════════════════════════════════════ */ import { useCallback, useEffect, useState } from "react"; import { del, get, patch } from "../../lib/api.ts"; import { isUnauthorized, useAuth } from "../../lib/auth.tsx"; import { useNavigate } from "react-router-dom"; import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts"; /* SQLite hands back "2026-09-22 04:11:07" — UTC, but without the marker that says so. Left alone, browsers read it as local time and last-login drifts by the timezone offset. */ function when(value) { if (!value) return "—"; const iso = value.includes("T") ? value : `${value.replace(" ", "T")}Z`; const date = new Date(iso); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } function uptime(seconds) { if (seconds == null) return "—"; const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); if (d) return `${d}d ${h}h`; if (h) return `${h}h ${m}m`; return `${m}m`; } function Block({ title, note, children }: any) { return (

{title}

{note &&

{note}

}
{children}
); } function Stat({ label, value }) { return (
{label}
{value}
); } export default function AdminPanel() { const { user: me } = useAuth(); const navigate = useNavigate(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Which row is mid-request, and what went wrong on it. Scoped to // the row so a failure on one account doesn't blank the table. const [busyId, setBusyId] = useState(null); const [rowError, setRowError] = useState(null); const load = useCallback(async () => { setLoading(true); setError(null); try { setData(await get("/admin/panel/overview", { ttl: 0 })); } catch (err: any) { if (isUnauthorized(err)) return navigate("/admin/login", { replace: true }); setError(err.message || "Couldn't load the panel."); } finally { setLoading(false); } }, [navigate]); useEffect(() => { load(); }, [load]); /* Replace the one row the server returns rather than refetching the whole overview — the counts didn't change. */ function mergeUser(updated) { setData((current) => current ? { ...current, users: current.users.map((u) => (u.id === updated.id ? updated : u)), } : current, ); } async function run(id, work) { setBusyId(id); setRowError(null); try { mergeUser(await work()); } catch (err: any) { if (isUnauthorized(err)) return navigate("/admin/login", { replace: true }); setRowError({ id, message: err.message || "That didn't work." }); } finally { setBusyId(null); } } const changeRole = (row, role) => run(row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { role })).user); const setActive = (row, is_active) => run( row.id, async () => (await patch(`/admin/panel/users/${row.id}`, { is_active })).user, ); const revoke = (row) => run(row.id, async () => (await del(`/admin/panel/users/${row.id}/sessions`)).user); if (loading) { return (

Loading the panel…

); } if (error) { return (

{error}

); } const { system, content, users } = data; const activeSupers = users.filter( (u) => u.role === "superadmin" && u.is_active === 1, ).length; return (

Panel

Accounts and server state. Everything here is superadmin-only.

{system.dbPath && (

{system.dbPath}

)}
{content.map((row) => ( {row.label}{" "} {row.count ?? "—"} ))}
{users.map((row) => { const isMe = row.id === me?.id; const lastSuper = row.role === "superadmin" && row.is_active === 1 && activeSupers <= 1; const locked = isMe || lastSuper; const busy = busyId === row.id; return ( ); })}
Account Role Last login Sessions Access
{row.name || row.email} {isMe && ( you )}
{row.name && (
{row.email}
)} {rowError?.id === row.id && (
{rowError.message}
)}
{when(row.last_login_at)} {row.sessions} {row.sessions > 0 && ( )}
{/* Each rung adds to the one above it. Worth stating, because "Editor" doesn't tell you where the line falls. */}
{ROLES.map((role) => (
{ROLE_LABELS[role]}
{ROLE_NOTES[role]}
))}

New accounts are still created with admin-cli.js on the server.

); }