Spaces:
Running
Running
| 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 ( | |
| <div className="stat"> | |
| <div className="v">{v}</div> | |
| <div className="k">{k}</div> | |
| </div> | |
| ); | |
| } | |
| // 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 ( | |
| <div className="card"> | |
| <h3 | |
| onClick={() => 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"} | |
| > | |
| <span>{title}{!open && hint ? <span className="muted small" style={{ fontWeight: 400 }}> · {hint}</span> : null}</span> | |
| <span className="muted small" style={{ fontWeight: 400 }}>{open ? "▾" : "▸"}</span> | |
| </h3> | |
| {open && children} | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="card"> | |
| <h3>Admin</h3> | |
| <p className="hint"> | |
| This page requires an admin account.{" "} | |
| <a href="/">Back to the platform</a>. | |
| </p> | |
| </div> | |
| ); | |
| } | |
| 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 && <div className="error-banner" onClick={() => setError("")}>{error}</div>} | |
| {notice && ( | |
| <div className="card" style={{ borderColor: "var(--brand)" }}> | |
| <p style={{ overflowWrap: "anywhere" }}><b>{notice}</b></p> | |
| </div> | |
| )} | |
| <div className="project-header"> | |
| <span className="project-title">Admin</span> | |
| <a className="ghost" href="/">← Back to dashboard</a> | |
| </div> | |
| {/* Overview */} | |
| <Section title="Overview"> | |
| {overview ? ( | |
| <> | |
| <div className="stat-grid"> | |
| <Stat k="Accounts" v={overview.users} /> | |
| <Stat k="Runs total" v={overview.runs_total} /> | |
| <Stat k="Projects" v={overview.projects} /> | |
| <Stat k="Awaiting verification" v={overview.constructs_unverified} /> | |
| </div> | |
| <p className="small muted" style={{ margin: "10px 0 0" }}> | |
| {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"} | |
| </p> | |
| </> | |
| ) : ( | |
| <p className="hint">Loading…</p> | |
| )} | |
| </Section> | |
| {/* Users */} | |
| <Section title="Users" hint={`${users.length} account${users.length === 1 ? "" : "s"}`}> | |
| <div className="table-wrap"> | |
| <table className="docs"> | |
| <thead> | |
| <tr><th>Email</th><th>Name</th><th>Role</th><th>Saved runs</th><th>Sign-in</th><th /></tr> | |
| </thead> | |
| <tbody> | |
| {users.map((u) => ( | |
| <tr key={u.id}> | |
| <td>{u.email}{u.is_admin ? " ★" : ""}</td> | |
| <td>{u.name}</td> | |
| <td> | |
| <select | |
| value={u.role} | |
| onChange={(e) => | |
| act(() => post(`/api/admin/users/${u.id}/role`, { role: e.target.value })) | |
| } | |
| > | |
| {ROLES.map((r) => ( | |
| <option key={r} value={r}>{ROLE_LABELS[r]}</option> | |
| ))} | |
| </select> | |
| </td> | |
| <td>{u.saved_runs}</td> | |
| <td className="muted small">{u.google_only ? "Google" : "password"}</td> | |
| <td> | |
| {!u.google_only && ( | |
| <button | |
| className="linkish" | |
| onClick={() => | |
| act(async () => { | |
| const r = await post(`/api/admin/users/${u.id}/reset-password`); | |
| setNotice(`Temporary password for ${r.email}: ${r.temporary_password}`); | |
| }) | |
| } | |
| > | |
| Reset password | |
| </button> | |
| )}{" "} | |
| {!u.env_admin && ( | |
| <button | |
| className="linkish danger" | |
| onClick={() => { | |
| if (window.confirm(`Delete ${u.email} and ALL their data?`)) { | |
| act(() => adminFetch(`/api/admin/users/${u.id}`, { method: "DELETE" })); | |
| } | |
| }} | |
| > | |
| Delete | |
| </button> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| {users.length === 0 && ( | |
| <tr><td colSpan={6} className="muted">No accounts yet.</td></tr> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| </Section> | |
| {/* Access before sign-in: pre-assigned roles + invite links */} | |
| <Section title="Access before sign-in"> | |
| <p className="hint"> | |
| <b>Pre-assign a role to an email</b> (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. | |
| </p> | |
| <form | |
| onSubmit={(e) => { | |
| 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" }} | |
| > | |
| <input | |
| type="email" | |
| placeholder="person@university.edu" | |
| value={assignEmail} | |
| onChange={(e) => setAssignEmail(e.target.value)} | |
| style={{ minWidth: "16rem" }} | |
| /> | |
| <select value={assignRole} onChange={(e) => setAssignRole(e.target.value)}> | |
| {ROLES.map((r) => ( | |
| <option key={r} value={r}>{ROLE_LABELS[r]}</option> | |
| ))} | |
| </select> | |
| <button type="submit" className="ghost" disabled={!assignEmail.trim()}>Pre-assign</button> | |
| </form> | |
| {assignments.length > 0 && ( | |
| <div className="table-wrap"> | |
| <table className="docs"> | |
| <thead> | |
| <tr><th>Email</th><th>Role</th><th>By</th><th>Status</th><th /></tr> | |
| </thead> | |
| <tbody> | |
| {assignments.map((a) => ( | |
| <tr key={a.id}> | |
| <td>{a.email}</td> | |
| <td>{ROLE_LABELS[a.role] || a.role}</td> | |
| <td className="muted small">{a.assigned_by}</td> | |
| <td className="muted small"> | |
| {a.claimed_at ? `claimed ${a.claimed_at.slice(0, 10)}` : "pending"} | |
| </td> | |
| <td> | |
| {!a.claimed_at && ( | |
| <button | |
| className="linkish danger" | |
| onClick={() => | |
| act(() => adminFetch(`/api/admin/role-assignments/${a.id}`, { method: "DELETE" })) | |
| } | |
| > | |
| Remove | |
| </button> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| {/* 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 && (<> | |
| <p className="hint" style={{ marginTop: "1rem" }}> | |
| <b>Or create an invite link</b> (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. | |
| </p> | |
| <div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}> | |
| <select value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}> | |
| <option value="lab">lab member</option> | |
| <option value="external">external user</option> | |
| </select> | |
| <button | |
| className="ghost" | |
| onClick={() => | |
| act(async () => { | |
| const r = await post("/api/admin/invites", { role: inviteRole }); | |
| await copyInvite(r); | |
| }) | |
| } | |
| > | |
| Create invite link | |
| </button> | |
| </div> | |
| {invites.length > 0 && ( | |
| <div className="table-wrap"> | |
| <table className="docs"> | |
| <thead> | |
| <tr><th>Role</th><th>Created</th><th>Expires</th><th>Status</th><th>Used by</th><th /></tr> | |
| </thead> | |
| <tbody> | |
| {invites.map((inv) => ( | |
| <tr key={inv.id}> | |
| <td>{ROLE_LABELS[inv.role] || inv.role}</td> | |
| <td className="muted small">{inv.created_at.slice(0, 10)} · {inv.created_by}</td> | |
| <td className="muted small">{inv.expires_at}</td> | |
| <td> | |
| <span className={`pill ${inv.status === "active" ? "completed" : "queued"}`}> | |
| {inv.status} | |
| </span> | |
| </td> | |
| <td className="small"> | |
| {inv.redemptions.length === 0 | |
| ? <span className="muted">nobody yet</span> | |
| : inv.redemptions.map((r) => ( | |
| <div key={r.email} title={r.at}>{r.email}</div> | |
| ))} | |
| </td> | |
| <td> | |
| {inv.status === "active" && ( | |
| <> | |
| <button className="linkish" onClick={() => act(() => copyInvite(inv))}> | |
| Copy link | |
| </button>{" "} | |
| <button | |
| className="linkish danger" | |
| onClick={() => { | |
| if (window.confirm("Revoke this invite link? It stops working immediately.")) { | |
| act(() => adminFetch(`/api/admin/invites/${inv.id}`, { method: "DELETE" })); | |
| } | |
| }} | |
| > | |
| Revoke | |
| </button> | |
| </> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </>)} | |
| </Section> | |
| {/* Audit trail - PI/env-admin only (404s/403s hide it for maintainers) */} | |
| {audit !== null && ( | |
| <Section title="Audit trail" hint={`${audit.length} recent entries`} defaultOpen={false}> | |
| {audit.length === 0 ? ( | |
| <p className="hint">No admin actions recorded yet.</p> | |
| ) : ( | |
| <div className="table-wrap"> | |
| <table className="docs"> | |
| <thead> | |
| <tr><th>When</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr> | |
| </thead> | |
| <tbody> | |
| {audit.map((a, i) => ( | |
| <tr key={i}> | |
| <td className="muted small">{a.at.replace("T", " ").slice(0, 16)}</td> | |
| <td className="small">{a.actor}</td> | |
| <td className="small">{a.action.replaceAll("_", " ")}</td> | |
| <td className="small">{a.target}</td> | |
| <td className="muted small">{a.detail}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </Section> | |
| )} | |
| {/* Failed runs */} | |
| <Section title="Failed runs" hint={failed.length ? `${failed.length} to triage` : "none"}> | |
| {failed.length === 0 ? ( | |
| <p className="hint">None. 🎉</p> | |
| ) : ( | |
| <div className="table-wrap"> | |
| <table className="docs"> | |
| <thead> | |
| <tr><th>When</th><th>Corpus</th><th>Model</th><th>Error</th><th /></tr> | |
| </thead> | |
| <tbody> | |
| {failed.map((j) => ( | |
| <tr key={j.id}> | |
| <td className="muted">{j.created_at.replace("T", " ").slice(0, 16)}</td> | |
| <td>{j.corpus_filename}</td> | |
| <td className="muted small">{j.model_name}</td> | |
| <td className="small" title={j.error_tail}>{j.error_tail.slice(0, 90)}</td> | |
| <td> | |
| {j.corpus_file_available ? ( | |
| <button | |
| className="linkish" | |
| onClick={() => act(() => post(`/api/admin/jobs/${j.id}/requeue`))} | |
| > | |
| Requeue | |
| </button> | |
| ) : ( | |
| <span className="muted small">file expired</span> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </Section> | |
| {/* Verification queue */} | |
| <Section | |
| title="Construct verification" | |
| hint={`${constructs.length} ${onlyUnverified ? "awaiting" : "listed"}`} | |
| defaultOpen={false} | |
| > | |
| <p className="hint"> | |
| 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."} | |
| {" "} | |
| <button className="linkish" onClick={() => setOnlyUnverified((v) => !v)}> | |
| {onlyUnverified ? "Show all" : "Show unverified only"} | |
| </button> | |
| </p> | |
| <div className="table-wrap"> | |
| <table className="docs"> | |
| <thead> | |
| <tr><th>Scale</th><th>Category</th><th>Items</th><th>Status</th><th /></tr> | |
| </thead> | |
| <tbody> | |
| {constructs.map((c) => ( | |
| <tr key={c.id}> | |
| <td title={c.reference}>{c.name}</td> | |
| <td className="muted small">{c.category}</td> | |
| <td>{c.n_items}</td> | |
| <td> | |
| <span className={`pill ${c.verification_status === "verified" ? "completed" : "queued"}`}> | |
| {c.verification_status.replace("_", " ")} | |
| </span> | |
| </td> | |
| <td> | |
| {canVerify && ( | |
| <button | |
| className="linkish" | |
| onClick={() => | |
| act(() => post(`/api/admin/constructs/${c.id}/verification`, { | |
| status: c.verification_status === "verified" | |
| ? "needs_verification" | |
| : "verified", | |
| })) | |
| } | |
| > | |
| {c.verification_status === "verified" ? "Un-verify" : "Mark verified"} | |
| </button> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| {constructs.length === 0 && ( | |
| <tr><td colSpan={5} className="muted">Nothing awaiting verification. 🎉</td></tr> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| </Section> | |
| </> | |
| ); | |
| } | |