// ============================================================================
// Admin screens — Users & access (two views) + Gradebook
//
// Access management is a master–detail with two switchable views, not a wide
// user×assignment matrix (which doesn't scale past a handful of problems):
// • By student — pick a student → category-grouped checklist of all problems
// • By assignment — pick a problem → student list with multi-select distribute
// Both use only POST/DELETE /admin/users/:id/access. New problems are authored
// from the command line, not here.
// ============================================================================
function groupByCategory(asgs) {
const m = new Map();
for (const a of asgs) {
const c = a.category || 'Other';
if (!m.has(c)) m.set(c, []);
m.get(c).push(a);
}
return [...m.entries()].map(([name, items]) => ({ name, items }));
}
// Square grant toggle (✓ granted / dash not-granted). `locked` = admin row.
function GrantToggle({ granted, locked, onClick }) {
return (
!locked && onClick()}
disabled={locked}
title={locked ? 'admins have all access' : granted ? 'revoke' : 'grant'}
style={{
width: 26, height: 26, borderRadius: 7, cursor: locked ? 'default' : 'pointer', flexShrink: 0,
display: 'inline-grid', placeItems: 'center', transition: 'all .14s',
border: `1px solid ${granted ? 'oklch(0.88 0.19 138 / 0.45)' : 'var(--line)'}`,
background: granted ? 'oklch(0.88 0.19 138 / 0.16)' : 'transparent',
color: granted ? 'var(--acc)' : 'var(--faint)', opacity: locked ? 0.55 : 1,
}}>
{granted ? : }
);
}
function CreateStudent({ onCreated }) {
const [nu, setNu] = useState('');
const [np, setNp] = useState('');
const [ferr, setFerr] = useState('');
const [busy, setBusy] = useState(false);
const toast = useToast();
const submit = async (e) => {
e.preventDefault(); setFerr('');
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(nu)) { setFerr('Username: 3–32 chars of A–Z a–z 0–9 _ . -'); return; }
if (np.length < 8) { setFerr('Password must be at least 8 characters.'); return; }
setBusy(true);
try { await api.createUser(nu, np); toast(`Created ${nu}.`); setNu(''); setNp(''); onCreated(); }
catch (er) { setFerr(er.error); } finally { setBusy(false); }
};
return (
);
}
// --------------------------------------------------- ADMIN · USERS ----------
function AdminUsers() {
const [view, setView] = useState('student');
const [users, setUsers] = useState(null);
const [asgs, setAsgs] = useState(null);
const toast = useToast();
const loadUsers = () => api.adminUsers().then((r) => setUsers(r.data.users));
useEffect(() => { loadUsers(); api.assignments().then((r) => setAsgs(r.data.assignments)); }, []);
// grant/revoke one (user, assignment), optimistic on the users list
const setGrant = async (userId, asgId, on) => {
setUsers((list) => list.map((u) => u.id === userId
? { ...u, access: on ? [...new Set([...u.access, asgId])] : u.access.filter((a) => a !== asgId) }
: u));
try { await api.grant(userId, asgId, on); }
catch (er) { toast(er.error, 'err'); loadUsers(); }
};
const ready = users && asgs;
return (
Admin · access control
Users & access
Grant which problems each student can attempt — pick a student, or distribute one problem to many students. New problems are added from the command line.
{[['student', 'user', 'By student'], ['assignment', 'book', 'By assignment']].map(([v, ico, label]) => (
setView(v)} style={{
border: 'none', background: view === v ? 'var(--panel-3)' : 'transparent',
color: view === v ? 'var(--text)' : 'var(--muted)',
}}> {label}
))}
{!ready ?
:
view === 'student'
?
:
}
);
}
// ---- view A: by student ----------------------------------------------------
function ByStudent({ users, asgs, setGrant, reload }) {
const [selId, setSelId] = useState(null);
const [q, setQ] = useState('');
const toast = useToast();
const students = users.filter((u) => u.role === 'student');
const sel = users.find((u) => u.id === selId) || null;
const groups = React.useMemo(() => groupByCategory(asgs), [asgs]);
// Multi-select: tick any number of problems (across groups) and grant/revoke
// them for the selected student in one action — student↔assignment is many-to-many.
const [picked, setPicked] = useState(() => new Set());
React.useEffect(() => { setPicked(new Set()); }, [selId]);
const togglePick = (id) => setPicked((p) => { const n = new Set(p); n.has(id) ? n.delete(id) : n.add(id); return n; });
const applyPicked = (on) => {
if (!sel) return;
[...picked].forEach((id) => { const g = sel.access.includes(id); if (on ? !g : g) setGrant(sel.id, id, on); });
setPicked(new Set());
};
const del = async (u, e) => {
e.stopPropagation();
if (!window.confirm(`Delete ${u.username}? This cascades their grants and results.`)) return;
try { await api.deleteUser(u.id); toast(`Deleted ${u.username}.`); if (selId === u.id) setSelId(null); reload(); }
catch (er) { toast(er.error, 'err'); }
};
const ql = q.trim().toLowerCase();
const match = (a) => !ql || a.id.toLowerCase().includes(ql) || a.title.toLowerCase().includes(ql);
return (
{/* left: create + students */}
{students.length === 0 ?
No students yet.
:
students.map((u) => (
setSelId(u.id)} style={{
display: 'flex', alignItems: 'center', gap: 11, padding: '12px 14px', cursor: 'pointer',
borderBottom: '1px solid var(--line)', background: selId === u.id ? 'var(--panel-3)' : 'transparent',
}}>
{u.username[0].toUpperCase()}
{u.username}
{u.access.length} / {asgs.length} granted
del(u, e)} title="delete">
))}
{/* right: access editor */}
{!sel ?
:
{sel.username[0].toUpperCase()}
{sel.username}
granted {sel.access.length} / {asgs.length}
setQ(e.target.value)} placeholder="Search problems…" style={{ width: 220 }} />
{picked.size > 0 && (
{picked.size} selected
applyPicked(true)}>Grant selected
applyPicked(false)}>Revoke selected
setPicked(new Set())}>Clear
)}
{groups.map((g) => {
const items = g.items.filter(match);
if (!items.length) return null;
return (
{g.name}
setPicked((p) => { const n = new Set(p); items.forEach((a) => n.add(a.id)); return n; })}>Select all
setPicked((p) => { const n = new Set(p); items.forEach((a) => n.delete(a.id)); return n; })}>Clear
{items.map((a) => {
const granted = sel.access.includes(a.id);
const isPicked = picked.has(a.id);
return (
togglePick(a.id)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '9px 12px', borderRadius: 9, cursor: 'pointer', border: `1px solid ${isPicked ? 'var(--acc-2)' : 'var(--line)'}`, background: isPicked ? 'oklch(0.88 0.19 138 / 0.06)' : 'transparent' }}>
{a.title}
#{a.id} · {a.totalPoints} pts
e.stopPropagation()} style={{ display: 'inline-flex' }}>
setGrant(sel.id, a.id, !granted)} />
);
})}
);
})}
}
);
}
// ---- view B: by assignment -------------------------------------------------
function ByAssignment({ users, asgs, setGrant }) {
const [selId, setSelId] = useState(null);
const [q, setQ] = useState('');
const [picked, setPicked] = useState(() => new Set());
const students = users.filter((u) => u.role === 'student');
const sel = asgs.find((a) => a.id === selId) || null;
const groups = React.useMemo(() => groupByCategory(asgs), [asgs]);
useEffect(() => { setPicked(new Set()); }, [selId]);
const countFor = (asgId) => students.filter((u) => u.access.includes(asgId)).length;
const ql = q.trim().toLowerCase();
const shown = students.filter((u) => !ql || u.username.toLowerCase().includes(ql));
const togglePick = (id) => setPicked((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const bulk = (on) => {
shown.forEach((u) => {
if (picked.has(u.id) && (on ? !u.access.includes(sel.id) : u.access.includes(sel.id))) setGrant(u.id, sel.id, on);
});
setPicked(new Set());
};
return (
{/* left: problems by category */}
{groups.map((g) => (
{g.name}
{g.items.map((a) => (
setSelId(a.id)} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', cursor: 'pointer',
borderBottom: '1px solid var(--line)', background: selId === a.id ? 'var(--panel-3)' : 'transparent',
}}>
{a.title}
#{a.id} · {countFor(a.id)} students
))}
))}
{/* right: students for the selected problem */}
{!sel ?
:
{sel.title}
#{sel.id} · granted to {countFor(sel.id)} / {students.length}
setQ(e.target.value)} placeholder="Search students…" style={{ width: 200 }} />
{picked.size > 0 && (
{picked.size} selected
bulk(true)}>Grant to selected
bulk(false)}>Revoke from selected
)}
{shown.length === 0 ?
No students match.
:
shown.map((u) => {
const granted = u.access.includes(sel.id);
return (
);
})}
}
);
}
// --------------------------------------------------- ADMIN · GRADEBOOK ------
function AdminGradebook() {
const [rows, setRows] = useState(null);
const [users, setUsers] = useState([]);
const [asgs, setAsgs] = useState([]);
const [fUser, setFUser] = useState('');
const [fAsg, setFAsg] = useState('');
const [modal, setModal] = useState(null); // { username, title, submission } — source viewer
const toast = useToast();
const [reloading, setReloading] = useState(false);
// Hot-reload the bank, then refresh the assignment axis so newly synced
// problems appear in the filter immediately.
const reload = async () => {
setReloading(true);
try {
const r = await api.reloadProblems();
const a = await api.assignments();
setAsgs(a.data.assignments);
toast(`Reloaded ${r.data.count} problems.`);
} catch (e) {
toast(e.error || 'Reload failed.', 'err');
} finally { setReloading(false); }
};
// Open the source viewer for one (student, assignment) cell — their latest code.
const openSubs = async (r) => {
setModal({ username: r.username, title: titleOf(r.assignmentId), submission: undefined });
try {
const res = await api.adminLastSubmission(r.userId, r.assignmentId);
setModal((m) => (m ? { ...m, submission: res.data.submission } : m));
} catch (e) {
setModal(null);
toast(e.error || 'Could not load the submission.', 'err');
}
};
useEffect(() => {
api.adminUsers().then((r) => setUsers(r.data.users.filter((u) => u.role === 'student')));
api.assignments().then((r) => setAsgs(r.data.assignments));
}, []);
useEffect(() => {
setRows(null);
const f = {};
if (fUser) f.userId = Number(fUser);
if (fAsg) f.assignmentId = fAsg;
api.gradebook(f).then((r) => setRows(r.data.gradebook));
}, [fUser, fAsg]);
const titleOf = (id) => asgs.find((a) => a.id === id)?.title || id;
const stats = rows ? {
rows: rows.length,
passed: rows.filter((r) => r.passed).length,
avg: rows.length ? Math.round(rows.reduce((s, r) => s + (r.best / r.total) * 100, 0) / rows.length) : 0,
} : null;
return (
Admin · whole class
Gradebook
Best score per student per assignment. Filter by student or problem.
{stats && (
)}
FILTERS
STUDENT
setFUser(e.target.value)}>
All students
{users.map((u) => {u.username} )}
ASSIGNMENT
setFAsg(e.target.value)}>
All assignments
{asgs.map((a) => {a.title} )}
{(fUser || fAsg) &&
{ setFUser(''); setFAsg(''); }}>Clear }
{reloading ? Reloading : <> Reload problems>}
{!rows ?
:
rows.length === 0 ?
:
Student Assignment Status Attempts Best Last submitted (UTC+8) Code
{rows.map((r, i) => (
{r.username[0].toUpperCase()} {r.username}
{titleOf(r.assignmentId)}
#{r.assignmentId}
{r.attempts}
{r.best} / {r.total}
{fmtTime(r.lastAt)}
openSubs(r)} title="View submitted source"> View
))}
}
{modal && (
setModal(null)}
title={`${modal.username} · ${modal.title}`}
sub="The student's latest submission — the exact source they last submitted.">
)}
);
}
function Stat({ label, value, accent }) {
return (
);
}
Object.assign(window, { AdminUsers, AdminGradebook });