ccr-platform / frontend /src /AdminPage.jsx
devaanand's picture
feat: put invite links on hold; single brand header everywhere
6a6c7cf
Raw
History Blame Contribute Delete
20.3 kB
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>
</>
);
}