feat(admin): add cohort and student management portal

New /admin route (frontend/src/admin/), gated by a separate admin
session (scrypt + timingSafeEqual against ADMIN_PASSWORD, no plaintext
storage, no new dependency). Covers cohort CRUD + roster + curriculum
assignment, and student CRUD + cohort assignment + environment/progress/
attempt views, all under /api/admin/* on the Platform API.

main.jsx now does a three-way path split (lab / admin / platform) so the
existing, unmodified Lab UI, the student dashboard, and the new admin
portal all ship in one Vite bundle without a router dependency.
This commit is contained in:
2026-08-18 12:22:58 -05:00
parent ae75bb4cbe
commit 8e4ef9ca53
7 changed files with 610 additions and 6 deletions
+133
View File
@@ -0,0 +1,133 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import { StatusPill } from './CohortsView'
import styles from './Admin.module.css'
export default function StudentsView() {
const [students, setStudents] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const reload = useCallback(() => {
adminApi.students().then(setStudents).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <StudentDetail studentId={selected} onBack={() => { setSelected(null); reload() }} />
return (
<div className={styles.card}>
<h2>Students ({students?.length ?? '…'})</h2>
{error && <div className={styles.errorBox}>{error}</div>}
{students && (
<table>
<thead><tr><th>Name</th><th>ID</th><th>Status</th><th>Last active</th><th /></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</td>
<td className={styles.muted}>{s.student_id}</td>
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function StudentDetail({ studentId, onBack }) {
const [progress, setProgress] = useState(null)
const [attempts, setAttempts] = useState(null)
const [env, setEnv] = useState(null)
const [events, setEvents] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
adminApi.studentAttempts(studentId).then(setAttempts).catch(() => {})
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
}, [studentId])
useEffect(reload, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
const loadEvents = async () => {
if (!env) return
try { setEvents(await adminApi.environmentEvents(env.environment_id)) } catch (e) { setError(e.message) }
}
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to students</button>
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h3>Environment</h3>
{!env ? <p className={styles.muted}>No Lab provisioned.</p> : (
<>
<p>Status: <StatusPill status={env.status} /> — Lab host: {env.lab_host_id} — created {new Date(env.created_at).toLocaleString()}</p>
{env.last_active_at && <p className={styles.muted}>Last active: {new Date(env.last_active_at).toLocaleString()}</p>}
</>
)}
<div className={styles.formRow} style={{ marginTop: '0.75rem' }}>
{!env && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.provisionStudentLab(studentId))}>Provision Lab</button>}
{env && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.resetEnvironment(env.environment_id))}>Reset/Recreate</button>}
{env && (
<button className={styles.btnDanger} disabled={busy} onClick={withBusy(async () => {
if (window.confirm('Destroy this Lab? Progress is not affected.')) await adminApi.destroyEnvironment(env.environment_id)
})}>Destroy Lab</button>
)}
{env && <button className={styles.linkBtn} onClick={loadEvents}>View event history</button>}
</div>
{events && (
<table style={{ marginTop: '0.75rem' }}>
<thead><tr><th>Event</th><th>Actor</th><th>When</th></tr></thead>
<tbody>
{events.map((e) => <tr key={e.id}><td>{e.event_type}</td><td>{e.actor}</td><td>{new Date(e.created_at).toLocaleString()}</td></tr>)}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Curriculum progress</h3>
{!progress ? <p className={styles.muted}>Loading…</p> : progress.stages.map((s) => (
<div key={s.id} style={{ marginBottom: '0.5rem' }}>
<strong>{s.name}</strong>
<div className={styles.muted}>
{s.scenarios.filter((sc) => sc.status === 'completed').length}/{s.scenarios.length} complete
</div>
</div>
))}
</div>
<div className={styles.card}>
<h3>Recent attempts</h3>
{!attempts ? <p className={styles.muted}>Loading…</p> : attempts.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Scenario</th><th>Result</th><th>Checks</th><th>When</th></tr></thead>
<tbody>
{attempts.map((a) => (
<tr key={a.attempt_id}>
<td>{a.scenario_id}</td>
<td><span className={`${styles.pill} ${a.result === 'passed' ? styles.pillOk : styles.pillWarn}`}>{a.result}</span></td>
<td className={styles.muted}>{a.validation_count ?? '—'}</td>
<td>{new Date(a.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}