Files
rootnode-academy/frontend/src/admin/AdminPortal.jsx
T
ced4568 8e4ef9ca53 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.
2026-08-18 12:22:58 -05:00

73 lines
2.8 KiB
React

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>
)
}