import { useCallback, useEffect, useState } from "react"; // Minimal admin surface (v1): overview counters, user roles + password // resets, failed-run requeue, and the RA's construct-verification queue. // Access is enforced server-side (ADMIN_EMAILS allowlist or pi/maintainer // role); this page just renders what the admin API returns. Actions a // maintainer isn't allowed to take (granting staff roles, touching staff // accounts) are rejected by the server and surface in the error banner. const ROLES = ["external", "lab", "maintainer", "pi"]; const ROLE_LABELS = { external: "external user", lab: "lab member", maintainer: "maintainer", pi: "PI", }; function Stat({ k, v }) { return (
{v}
{k}
); } // Every admin card is collapsible; the big ones (verification queue, audit) // start closed so the page stays scannable. function Section({ title, hint, defaultOpen = true, children }) { const [open, setOpen] = useState(defaultOpen); return (

setOpen((v) => !v)} style={{ cursor: "pointer", userSelect: "none", display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: ".6rem", marginBottom: open ? undefined : 0, }} title={open ? "Click to collapse" : "Click to expand"} > {title}{!open && hint ? · {hint} : null} {open ? "▾" : "▸"}

{open && children}
); } async function adminFetch(path, options = {}) { const resp = await fetch(path, options); if (!resp.ok) { let detail = resp.statusText; try { detail = (await resp.json()).detail || detail; } catch { /* non-JSON */ } throw new Error(detail); } return resp.status === 204 ? null : resp.json(); } const post = (path, body) => adminFetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), }); export default function AdminPage({ auth }) { const [overview, setOverview] = useState(null); const [users, setUsers] = useState([]); const [failed, setFailed] = useState([]); const [constructs, setConstructs] = useState([]); const [assignments, setAssignments] = useState([]); const [invites, setInvites] = useState([]); const [audit, setAudit] = useState(null); // null = not visible (maintainers) const [onlyUnverified, setOnlyUnverified] = useState(true); const [inviteRole, setInviteRole] = useState("lab"); const [assignEmail, setAssignEmail] = useState(""); const [assignRole, setAssignRole] = useState("lab"); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); // Only maintainers can flip verification statuses (the RA workflow); // PI/admin see the queue read-only. Enforced server-side too. const canVerify = auth?.role === "maintainer"; const reload = useCallback(() => { setError(""); adminFetch("/api/admin/overview").then(setOverview).catch((e) => setError(e.message)); adminFetch("/api/admin/users").then(setUsers).catch(() => {}); adminFetch("/api/admin/jobs/failed").then(setFailed).catch(() => {}); adminFetch("/api/admin/role-assignments").then(setAssignments).catch(() => {}); adminFetch("/api/admin/invites").then(setInvites).catch(() => {}); adminFetch("/api/admin/audit").then(setAudit).catch(() => setAudit(null)); // 403 for maintainers adminFetch( "/api/admin/constructs" + (onlyUnverified ? "?status=needs_verification" : "") ).then(setConstructs).catch(() => {}); }, [onlyUnverified]); useEffect(() => { reload(); }, [reload]); if (!auth?.signed_in || !auth?.is_admin) { return (

Admin

This page requires an admin account.{" "} Back to the platform.

); } async function act(fn) { setError(""); setNotice(""); try { await fn(); reload(); } catch (e) { setError(e.message); } } async function copyInvite(inv) { const url = `${window.location.origin}/?invite=${encodeURIComponent(inv.token)}`; try { await navigator.clipboard.writeText(url); setNotice(`Invite link copied (${ROLE_LABELS[inv.role]}, expires ${inv.expires_at}). Paste it in Slack.`); } catch { setNotice(`Could not copy automatically - the link: ${url}`); } } return ( <> {error &&
setError("")}>{error}
} {notice && (

{notice}

)}
Admin ← Back to dashboard
{/* Overview */}
{overview ? ( <>

{ROLES.filter((r) => overview.users_by_role?.[r]) .map((r) => `${overview.users_by_role[r]} ${ROLE_LABELS[r]}`) .join(", ") || "no accounts yet"} {" "}· {overview.signups_last_7_days} sign-up {overview.signups_last_7_days === 1 ? "" : "s"} and{" "} {overview.runs_last_7_days} run{overview.runs_last_7_days === 1 ? "" : "s"} this week {overview.runs_by_status?.failed ? ` · ${overview.runs_by_status.failed} failed run${overview.runs_by_status.failed === 1 ? "" : "s"} total` : ""} {" "}· {overview.anonymous_projects} anonymous project {overview.anonymous_projects === 1 ? "" : "s"}

) : (

Loading…

)}
{/* Users */}
{users.map((u) => ( ))} {users.length === 0 && ( )}
EmailNameRoleSaved runsSign-in
{u.email}{u.is_admin ? " ★" : ""} {u.name} {u.saved_runs} {u.google_only ? "Google" : "password"} {!u.google_only && ( )}{" "} {!u.env_admin && ( )}
No accounts yet.
{/* Access before sign-in: pre-assigned roles + invite links */}

Pre-assign a role to an email (e.g. an external collaborator who should land with full credentials): whoever first signs in with that email - password or Google - gets the role automatically.

{ e.preventDefault(); if (!assignEmail.trim()) return; act(async () => { await post("/api/admin/role-assignments", { email: assignEmail.trim(), role: assignRole, }); setAssignEmail(""); }); }} style={{ display: "flex", gap: ".5rem", flexWrap: "wrap", alignItems: "center" }} > setAssignEmail(e.target.value)} style={{ minWidth: "16rem" }} />
{assignments.length > 0 && (
{assignments.map((a) => ( ))}
EmailRoleByStatus
{a.email} {ROLE_LABELS[a.role] || a.role} {a.assigned_by} {a.claimed_at ? `claimed ${a.claimed_at.slice(0, 10)}` : "pending"} {!a.claimed_at && ( )}
)} {/* Invite links are ON HOLD (2026-07-31): the server refuses creation and redemption while overview.invites_enabled is false, and this whole block stays hidden. Pre-assignments above are the way in. */} {overview?.invites_enabled && (<>

Or create an invite link (anyone with the link; lab member / external only - staff is granted per person above): paste it in Slack, it expires after a week. Revoking kills a link immediately; "used by" shows every account created through it.

{invites.length > 0 && (
{invites.map((inv) => ( ))}
RoleCreatedExpiresStatusUsed by
{ROLE_LABELS[inv.role] || inv.role} {inv.created_at.slice(0, 10)} · {inv.created_by} {inv.expires_at} {inv.status} {inv.redemptions.length === 0 ? nobody yet : inv.redemptions.map((r) => (
{r.email}
))}
{inv.status === "active" && ( <> {" "} )}
)} )}
{/* Audit trail - PI/env-admin only (404s/403s hide it for maintainers) */} {audit !== null && (
{audit.length === 0 ? (

No admin actions recorded yet.

) : (
{audit.map((a, i) => ( ))}
WhenWhoActionTargetDetail
{a.at.replace("T", " ").slice(0, 16)} {a.actor} {a.action.replaceAll("_", " ")} {a.target} {a.detail}
)}
)} {/* Failed runs */}
{failed.length === 0 ? (

None. 🎉

) : (
{failed.map((j) => ( ))}
WhenCorpusModelError
{j.created_at.replace("T", " ").slice(0, 16)} {j.corpus_filename} {j.model_name} {j.error_tail.slice(0, 90)} {j.corpus_file_available ? ( ) : ( file expired )}
)}
{/* Verification queue */}

The maintainer's workflow: mark a scale verified once its wording is checked against the original publication (cross-reference the verification checklist spreadsheet). Statuses set here are applied back to the library files before production. {!canVerify && " Your account has read access; verification actions are for maintainers."} {" "}

{constructs.map((c) => ( ))} {constructs.length === 0 && ( )}
ScaleCategoryItemsStatus
{c.name} {c.category} {c.n_items} {c.verification_status.replace("_", " ")} {canVerify && ( )}
Nothing awaiting verification. 🎉
); }