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
+59
View File
@@ -0,0 +1,59 @@
/* Admin portal — visually distinct from the student shell (a muted accent instead of
--green) so instructors and students never mistake which mode they're in, while still
reusing the same design tokens for everything else. */
.wrap { min-height: 100vh; background: var(--bg); color: var(--text); font-family: var(--sans); }
.loginWrap { height: 100vh; display: flex; align-items: center; justify-content: center; }
.loginCard { width: 360px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 2rem; }
.brand { font-family: var(--mono); font-size: 1.2rem; font-weight: 600; color: var(--blue); display: flex; align-items: center; gap: 0.5rem; }
.subtitle { color: var(--text-2); font-size: 0.85rem; margin: 0.4rem 0 1.25rem; }
.input, .select {
width: 100%; padding: 0.55rem 0.6rem; margin-top: 0.3rem;
background: var(--surface2); border: 1px solid var(--border2); border-radius: var(--radius);
color: var(--text); font-family: var(--sans); font-size: 0.9rem;
}
.label { display: block; font-size: 0.8rem; color: var(--text-2); margin-top: 0.9rem; }
.btn {
padding: 0.55rem 0.9rem; background: var(--blue); color: #04141c; border: none;
border-radius: var(--radius); font-weight: 600; font-size: 0.85rem; cursor: pointer;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btnSecondary { padding: 0.5rem 0.8rem; background: transparent; color: var(--text); border: 1px solid var(--border2); border-radius: var(--radius); font-size: 0.85rem; cursor: pointer; }
.btnDanger { padding: 0.5rem 0.8rem; background: transparent; color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); font-size: 0.85rem; cursor: pointer; }
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 2rem; border-bottom: 1px solid var(--border); }
.nav { display: flex; gap: 1.25rem; }
.navLink { color: var(--text-2); cursor: pointer; font-size: 0.9rem; background: none; border: none; padding: 0.3rem 0; }
.navLinkActive { color: var(--blue); border-bottom: 2px solid var(--blue); }
.content { max-width: 1000px; margin: 1.5rem auto; padding: 0 1.5rem; }
.statGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 1.5rem; }
.statCard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
.statValue { font-size: 1.6rem; font-weight: 700; }
.statLabel { color: var(--text-2); font-size: 0.78rem; margin-top: 0.2rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 1.25rem; margin-bottom: 1.25rem; }
.card h2, .card h3 { margin-bottom: 0.75rem; font-size: 1rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; color: var(--text-2); font-weight: 600; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border2); font-size: 0.75rem; text-transform: uppercase; }
td { padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
tr:hover td { background: var(--surface2); }
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 0.75rem; font-family: var(--mono); }
.pillOk { background: var(--green-dim); color: var(--green); }
.pillWarn { background: var(--amber-dim); color: var(--amber); }
.pillErr { background: var(--red-dim); color: var(--red); }
.pillMuted { background: var(--surface3); color: var(--text-2); }
.formRow { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 1rem; flex-wrap: wrap; }
.formRow > div { flex: 1; min-width: 140px; }
.linkBtn { background: none; border: none; color: var(--blue); cursor: pointer; font-size: 0.85rem; padding: 0; text-decoration: underline; }
.errorBox { background: var(--red-dim); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius); padding: 0.6rem 0.8rem; font-size: 0.85rem; margin-bottom: 1rem; }
.muted { color: var(--text-2); font-size: 0.85rem; }
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Operational overview — active cohorts, students, Lab states, recent activity. Deliberately
// lean (no Prometheus/Grafana here, see docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §9) — this is
// "is class ready / is anything on fire," not a metrics platform.
export default function AdminDashboard() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
adminApi.dashboard().then(setData).catch((e) => setError(e.message))
}, [])
if (error) return <div className={styles.errorBox}>{error}</div>
if (!data) return <p className={styles.muted}>Loading…</p>
return (
<>
<div className={styles.statGrid}>
<Stat label="Active cohorts" value={data.active_cohorts} />
<Stat label="Total students" value={data.total_students} />
<Stat label="Labs running" value={data.labs.running} />
<Stat label="Labs provisioning" value={data.labs.provisioning} />
<Stat label="Labs in error" value={data.labs.error} />
<Stat label="Labs idle/destroyed" value={data.labs.idle_or_destroyed} />
</div>
<div className={styles.card}>
<h3>Recent completions</h3>
{data.recent_completions.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Scenario</th><th>Completed</th></tr></thead>
<tbody>
{data.recent_completions.map((c, i) => (
<tr key={i}><td>{c.display_name}</td><td>{c.scenario_id}</td><td>{new Date(c.completed_at).toLocaleString()}</td></tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Recent environment failures</h3>
{data.recent_failures.length === 0 ? <p className={styles.muted}>None.</p> : (
<table>
<thead><tr><th>Student</th><th>Reason</th><th>When</th></tr></thead>
<tbody>
{data.recent_failures.map((f, i) => (
<tr key={i}><td>{f.student_id}</td><td>{f.error_reason || '—'}</td><td>{f.started_at ? new Date(f.started_at).toLocaleString() : '—'}</td></tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function Stat({ label, value }) {
return (
<div className={styles.statCard}>
<div className={styles.statValue}>{value}</div>
<div className={styles.statLabel}>{label}</div>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { useState, useEffect } from 'react'
import { adminApi } from './api'
import AdminDashboard from './AdminDashboard'
import CohortsView from './CohortsView'
import StudentsView from './StudentsView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
// separate rn_admin session cookie, separate login). A student session cannot reach any of
// these views or their underlying /api/admin/* routes — see
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §10.
export default function AdminPortal() {
const [authed, setAuthed] = useState(null) // null = checking, false = need login, true = in
const [view, setView] = useState('dashboard')
useEffect(() => {
adminApi.whoami().then(() => setAuthed(true)).catch(() => setAuthed(false))
}, [])
if (authed === null) return null
if (!authed) return <AdminLogin onLogin={() => setAuthed(true)} />
return (
<div className={styles.wrap}>
<header className={styles.header}>
<div className={styles.brand}>⚙ RootNode Admin</div>
<nav className={styles.nav}>
{['dashboard', 'cohorts', 'students'].map((v) => (
<button key={v} className={`${styles.navLink} ${view === v ? styles.navLinkActive : ''}`} onClick={() => setView(v)}>
{v[0].toUpperCase() + v.slice(1)}
</button>
))}
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setAuthed(false))}>Sign out</button>
</nav>
</header>
<div className={styles.content}>
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView />}
{view === 'students' && <StudentsView />}
</div>
</div>
)
}
function AdminLogin({ onLogin }) {
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try { await adminApi.login(password); onLogin() }
catch (e) { setError('Incorrect password.') }
finally { setBusy(false) }
}
return (
<div className={styles.loginWrap}>
<form className={styles.loginCard} onSubmit={submit}>
<div className={styles.brand}>⚙ RootNode Admin</div>
<p className={styles.subtitle}>Instructor sign-in. Not connected to student accounts.</p>
{error && <div className={styles.errorBox}>{error}</div>}
<label className={styles.label}>Password</label>
<input className={styles.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus />
<button className={styles.btn} style={{ marginTop: '1.25rem', width: '100%' }} disabled={busy || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
)
}
+214
View File
@@ -0,0 +1,214 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
export default function CohortsView() {
const [cohorts, setCohorts] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const reload = useCallback(() => {
adminApi.cohorts().then(setCohorts).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <CohortDetail cohortId={selected} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h2>Create cohort</h2>
<CreateCohortForm onCreated={reload} />
</div>
<div className={styles.card}>
<h2>Cohorts</h2>
{!cohorts ? <p className={styles.muted}>Loading…</p> : cohorts.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Name</th><th>Tier</th><th>Students</th><th>Status</th><th /></tr></thead>
<tbody>
{cohorts.map((c) => (
<tr key={c.cohort_id}>
<td>{c.name}</td>
<td>{c.tier || '—'}</td>
<td>{c.student_count}</td>
<td><span className={`${styles.pill} ${c.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{c.status}</span></td>
<td><button className={styles.linkBtn} onClick={() => setSelected(c.cohort_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateCohortForm({ onCreated }) {
const [form, setForm] = useState({ cohort_id: '', name: '', tier: '', start_date: '', end_date: '' })
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createCohort(form)
setForm({ cohort_id: '', name: '', tier: '', start_date: '', end_date: '' })
onCreated()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Cohort ID (slug)</label>
<input className={styles.input} placeholder="root-fall-2026" value={form.cohort_id} onChange={(e) => setForm({ ...form, cohort_id: e.target.value })} required /></div>
<div><label className={styles.label}>Name</label>
<input className={styles.input} placeholder="Root Fall 2026" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
<div><label className={styles.label}>Tier</label>
<input className={styles.input} placeholder="root" value={form.tier} onChange={(e) => setForm({ ...form, tier: e.target.value })} /></div>
<div><label className={styles.label}>Start date</label>
<input className={styles.input} type="date" value={form.start_date} onChange={(e) => setForm({ ...form, start_date: e.target.value })} /></div>
<div><label className={styles.label}>End date</label>
<input className={styles.input} type="date" value={form.end_date} onChange={(e) => setForm({ ...form, end_date: e.target.value })} /></div>
<div><button className={styles.btn} disabled={busy}>{busy ? 'Creating…' : 'Create cohort'}</button></div>
</div>
</form>
)
}
function CohortDetail({ cohortId, onBack }) {
const [students, setStudents] = useState(null)
const [progress, setProgress] = useState(null)
const [prepResults, setPrepResults] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [newStudentName, setNewStudentName] = useState('')
const reload = useCallback(() => {
adminApi.cohortStudents(cohortId).then(setStudents).catch((e) => setError(e.message))
adminApi.cohortProgress(cohortId).then(setProgress).catch(() => {})
}, [cohortId])
useEffect(reload, [reload])
const addStudent = async (e) => {
e.preventDefault()
if (!newStudentName.trim()) return
setBusy(true); setError(null)
try {
await adminApi.createStudent(newStudentName.trim(), cohortId)
setNewStudentName('')
reload()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
const prepareLabs = async () => {
setBusy(true); setError(null); setPrepResults(null)
try {
const { results } = await adminApi.prepareLabs(cohortId)
setPrepResults(results)
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
const destroyLabs = async () => {
if (!window.confirm(`Destroy all Labs for this cohort? Academic progress is not affected.`)) return
setBusy(true); setError(null)
try {
const { results } = await adminApi.destroyLabs(cohortId)
setPrepResults(results)
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
const assignCurriculum = async () => {
setBusy(true)
try { await adminApi.assignCohortCurriculum(cohortId, 'rootnode-path') } catch (e) { setError(e.message) } finally { setBusy(false) }
}
const readyCount = prepResults?.filter((r) => r.status === 'running').length
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to cohorts</button>
<h2 style={{ margin: '1rem 0' }}>{cohortId}</h2>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h3>Roster ({students?.length ?? '…'})</h3>
<form onSubmit={addStudent} className={styles.formRow}>
<div><input className={styles.input} placeholder="New student name" value={newStudentName} onChange={(e) => setNewStudentName(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy}>Add student</button></div>
</form>
{students && (
<table>
<thead><tr><th>Name</th><th>Status</th><th>Last active</th></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</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>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Curriculum</h3>
<p className={styles.muted}>RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)</p>
<button className={styles.btnSecondary} onClick={assignCurriculum} disabled={busy}>Assign RootNode Path to cohort</button>
</div>
{progress && (
<div className={styles.card}>
<h3>Cohort progress ({progress.student_count} students)</h3>
{progress.stages.map((s) => (
<div key={s.id} style={{ marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem' }}>
<span>{s.name}</span><span className={styles.muted}>{s.pct}%</span>
</div>
<div style={{ background: 'var(--surface3)', borderRadius: 4, height: 6 }}>
<div style={{ background: 'var(--green)', borderRadius: 4, height: 6, width: `${s.pct}%` }} />
</div>
</div>
))}
</div>
)}
<div className={styles.card}>
<h3>Classroom preparation</h3>
<div className={styles.formRow}>
<button className={styles.btn} onClick={prepareLabs} disabled={busy}>{busy ? 'Working…' : 'Prepare Labs'}</button>
<button className={styles.btnDanger} onClick={destroyLabs} disabled={busy}>Destroy Cohort Labs</button>
</div>
{prepResults && (
<>
{readyCount !== undefined && <p className={styles.muted}>{readyCount}/{prepResults.length} READY</p>}
<table>
<thead><tr><th>Student</th><th>Status</th></tr></thead>
<tbody>
{prepResults.map((r) => (
<tr key={r.student_id}>
<td>{r.display_name || r.student_id}</td>
<td><StatusPill status={r.status} /></td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</>
)
}
export function StatusPill({ status }) {
const cls = status === 'running' ? styles.pillOk
: status === 'error' ? styles.pillErr
: ['provisioning', 'requested', 'resetting'].includes(status) ? styles.pillWarn
: styles.pillMuted
return <span className={`${styles.pill} ${cls}`}>{status}</span>
}
+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>
</>
)
}
+56
View File
@@ -0,0 +1,56 @@
// Admin API client. Uses the rn_admin session cookie exclusively (same-origin, sent
// automatically) — completely separate credential/cookie from student sessions (see
// platform/api/src/sessions.js). No admin data is ever cached client-side beyond the current
// React state; nothing here reads or writes localStorage.
async function json(res) {
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || body.error || `Request failed (${res.status})`)
}
return res.json()
}
function post(path, body) {
return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(json)
}
function patch(path, body) {
return fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(json)
}
function del(path) {
return fetch(path, { method: 'DELETE' }).then(json)
}
function get(path) {
return fetch(path).then(json)
}
export const adminApi = {
login: (password) => post('/api/admin/login', { password }),
logout: () => post('/api/admin/logout'),
whoami: () => get('/api/admin/whoami'),
dashboard: () => get('/api/admin/dashboard'),
students: () => get('/api/admin/students'),
createStudent: (display_name, cohort_id) => post('/api/admin/students', { display_name, cohort_id }),
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
studentAttempts: (id) => get(`/api/admin/students/${id}/attempts`),
studentEnvironment: (id) => get(`/api/admin/students/${id}/environment`),
provisionStudentLab: (id) => post(`/api/admin/students/${id}/environment/provision`),
assignStudentCohort: (id, cohort_id) => post(`/api/admin/students/${id}/cohort`, { cohort_id }),
removeStudentCohort: (id, cohortId) => del(`/api/admin/students/${id}/cohort/${cohortId}`),
cohorts: () => get('/api/admin/cohorts'),
createCohort: (fields) => post('/api/admin/cohorts', fields),
updateCohort: (id, fields) => patch(`/api/admin/cohorts/${id}`, fields),
cohortStudents: (id) => get(`/api/admin/cohorts/${id}/students`),
cohortProgress: (id) => get(`/api/admin/cohorts/${id}/progress`),
assignCohortCurriculum: (id, curriculum_path) => post(`/api/admin/cohorts/${id}/curriculum`, { curriculum_path }),
prepareLabs: (id) => post(`/api/admin/cohorts/${id}/prepare-labs`),
destroyLabs: (id) => post(`/api/admin/cohorts/${id}/destroy-labs`),
resetEnvironment: (id) => post(`/api/admin/environments/${id}/reset`),
destroyEnvironment: (id) => del(`/api/admin/environments/${id}`),
environmentEvents: (id) => get(`/api/admin/environments/${id}/events`),
}
+8 -6
View File
@@ -2,22 +2,24 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import PlatformShell from './platform/PlatformShell.jsx'
import AdminPortal from './admin/AdminPortal.jsx'
import './index.css'
// Apply saved theme before first paint to avoid flash
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
document.documentElement.setAttribute('data-theme', savedTheme)
// This bundle is served both by the persistent identity/platform instance (root paths) and by
// This bundle is served both by the lightweight Platform API (root paths + /admin) and by
// every ephemeral per-student Lab container (reached via the Gateway at /lab/<token>/) — same
// build, same code, split purely by the URL path at runtime. See
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student flow" for why: it lets the existing Lab UI
// (<App/>) stay completely unmodified while the new Platform Shell (entry/dashboard/launch)
// lives alongside it in the same bundle.
const isLabSession = window.location.pathname.startsWith('/lab/')
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Student/admin flow" for why: it lets the existing
// Lab UI (<App/>) stay completely unmodified while the Platform Shell and Admin Portal live
// alongside it in the same bundle.
const path = window.location.pathname
const view = path.startsWith('/lab/') ? 'lab' : path.startsWith('/admin') ? 'admin' : 'platform'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
{isLabSession ? <App /> : <PlatformShell />}
{view === 'lab' ? <App /> : view === 'admin' ? <AdminPortal /> : <PlatformShell />}
</React.StrictMode>
)