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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user