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:
@@ -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}>← 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user