feat(admin): add instructor management, audit, and account UI

Login form is now email+password (was a bare shared password field). Nav conditionally
shows Instructors (owner only) and Audit (owner/admin) tabs based on the signed-in
instructor's role. New InstructorsView (create/list/edit accounts, role/status controls,
session list + revoke-all, password reset — owner only), AuditView (read-only audit event
table), AccountView (change-password form for the signed-in instructor). CohortsView hides
the create-cohort form and curriculum-assignment button for the 'instructor' role — the
server already enforces this; this just avoids showing controls that would 403.
This commit is contained in:
2026-08-18 13:36:29 -05:00
parent 7ce435ad32
commit 777a1ef880
5 changed files with 327 additions and 36 deletions
+39
View File
@@ -0,0 +1,39 @@
import { useState } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
export default function AccountView({ instructor }) {
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setNotice(null)
try {
await adminApi.changePassword(oldPassword, newPassword)
setOldPassword(''); setNewPassword('')
setNotice('Password changed. Any other sessions you had open elsewhere were signed out.')
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<div className={styles.card}>
<h2>Account</h2>
<p className={styles.muted}>{instructor.display_name} · {instructor.email} · role: {instructor.role}</p>
<h3 style={{ marginTop: '1.5rem' }}>Change password</h3>
<form onSubmit={submit} className={styles.formRow}>
{error && <div className={styles.errorBox}>{error}</div>}
<div><label className={styles.label}>Current password</label>
<input className={styles.input} type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} autoComplete="current-password" required /></div>
<div><label className={styles.label}>New password (12+ chars)</label>
<input className={styles.input} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="new-password" minLength={12} required /></div>
<div><button className={styles.btn} disabled={busy}>{busy ? 'Changing…' : 'Change password'}</button></div>
</form>
{notice && <p className={styles.muted}>{notice}</p>}
</div>
)
}
+47 -20
View File
@@ -3,46 +3,66 @@ import { adminApi } from './api'
import AdminDashboard from './AdminDashboard'
import CohortsView from './CohortsView'
import StudentsView from './StudentsView'
import InstructorsView from './InstructorsView'
import AuditView from './AuditView'
import AccountView from './AccountView'
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.
// separate rn_instructor session cookie, separate login). A student session cannot reach any
// of these views or their underlying /api/admin/* routes. Per-person accounts replace Phase
// 2B's single shared ADMIN_PASSWORD — see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
const NAV = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
]
export default function AdminPortal() {
const [authed, setAuthed] = useState(null) // null = checking, false = need login, true = in
const [instructor, setInstructor] = useState(null) // null = checking, false = need login, object = signed in
const [view, setView] = useState('dashboard')
useEffect(() => {
adminApi.whoami().then(() => setAuthed(true)).catch(() => setAuthed(false))
adminApi.session().then((r) => setInstructor(r.instructor)).catch(() => setInstructor(false))
}, [])
if (authed === null) return null
if (!authed) return <AdminLogin onLogin={() => setAuthed(true)} />
if (instructor === null) return null
if (!instructor) return <InstructorLogin onLogin={setInstructor} />
const visibleNav = NAV.filter((item) => !item.roles || item.roles.includes(instructor.role))
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)}
{visibleNav.map((item) => (
<button key={item.key} className={`${styles.navLink} ${view === item.key ? styles.navLinkActive : ''}`} onClick={() => setView(item.key)}>
{item.label}
</button>
))}
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setAuthed(false))}>Sign out</button>
<button className={`${styles.navLink} ${view === 'account' ? styles.navLinkActive : ''}`} onClick={() => setView('account')}>
{instructor.display_name}
</button>
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setInstructor(false))}>Sign out</button>
</nav>
</header>
<div className={styles.content}>
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView />}
{view === 'students' && <StudentsView />}
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
{view === 'account' && <AccountView instructor={instructor} />}
</div>
</div>
)
}
function AdminLogin({ onLogin }) {
function InstructorLogin({ onLogin }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
@@ -50,9 +70,14 @@ function AdminLogin({ onLogin }) {
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try { await adminApi.login(password); onLogin() }
catch (e) { setError('Incorrect password.') }
finally { setBusy(false) }
try {
const { instructor } = await adminApi.login(email, password)
onLogin(instructor)
} catch (e) {
setError(e.message || 'Sign-in failed.')
} finally {
setBusy(false)
}
}
return (
@@ -61,9 +86,11 @@ function AdminLogin({ onLogin }) {
<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}>
<label className={styles.label}>Email</label>
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus autoComplete="username" />
<label className={styles.label} style={{ marginTop: '0.75rem' }}>Password</label>
<input className={styles.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
<button className={styles.btn} style={{ marginTop: '1.25rem', width: '100%' }} disabled={busy || !email || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
+38
View File
@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// admin/owner only (enforced server-side too). Latest 200 events, no pagination UI — this is a
// pilot-scale audit log, not a SIEM; add pagination if that ever stops being true.
export default function AuditView() {
const [events, setEvents] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
adminApi.auditEvents().then(setEvents).catch((e) => setError(e.message))
}, [])
return (
<div className={styles.card}>
<h2>Audit log</h2>
<p className={styles.muted}>Most recent 200 security/admin events.</p>
{error && <div className={styles.errorBox}>{error}</div>}
{!events ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
<tbody>
{events.map((e) => (
<tr key={e.audit_event_id}>
<td>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
<td>{e.action}</td>
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
+23 -16
View File
@@ -2,25 +2,28 @@ import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
export default function CohortsView() {
export default function CohortsView({ role }) {
const [cohorts, setCohorts] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canManage = role !== 'instructor' // owner/admin only — a plain instructor works within cohorts already assigned to them
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() }} />
if (selected) return <CohortDetail cohortId={selected} canManage={canManage} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h2>Create cohort</h2>
<CreateCohortForm onCreated={reload} />
</div>
{canManage && (
<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> : (
@@ -79,7 +82,7 @@ function CreateCohortForm({ onCreated }) {
)
}
function CohortDetail({ cohortId, onBack }) {
function CohortDetail({ cohortId, canManage, onBack }) {
const [students, setStudents] = useState(null)
const [progress, setProgress] = useState(null)
const [prepResults, setPrepResults] = useState(null)
@@ -136,10 +139,12 @@ function CohortDetail({ cohortId, onBack }) {
<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>
{canManage && (
<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>
@@ -156,11 +161,13 @@ function CohortDetail({ cohortId, onBack }) {
)}
</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>
{canManage && (
<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}>
+180
View File
@@ -0,0 +1,180 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Owner-only — the server enforces this too (requireOwner on every /api/admin/instructors*
// route); this component is only ever mounted for an owner, but the backend never trusts that.
export default function InstructorsView({ currentInstructorId }) {
const [instructors, setInstructors] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const reload = useCallback(() => {
adminApi.instructors().then(setInstructors).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <InstructorDetail instructorId={selected} isSelf={selected === currentInstructorId} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h2>Add instructor</h2>
<CreateInstructorForm onCreated={reload} />
</div>
<div className={styles.card}>
<h2>Instructors</h2>
{!instructors ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th /></tr></thead>
<tbody>
{instructors.map((i) => (
<tr key={i.instructor_id}>
<td>{i.display_name}</td>
<td>{i.email}</td>
<td><span className={styles.pill}>{i.role}</span></td>
<td><span className={`${styles.pill} ${i.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{i.status}</span></td>
<td>{i.last_login_at ? new Date(i.last_login_at).toLocaleString() : 'never'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(i.instructor_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateInstructorForm({ onCreated }) {
const [form, setForm] = useState({ email: '', display_name: '', role: 'instructor', password: '' })
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createInstructor(form)
setForm({ email: '', display_name: '', role: 'instructor', password: '' })
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}>Name</label>
<input className={styles.input} value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} required /></div>
<div><label className={styles.label}>Email</label>
<input className={styles.input} type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /></div>
<div><label className={styles.label}>Role</label>
<select className={styles.select} value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="instructor">instructor</option>
<option value="admin">admin</option>
<option value="owner">owner</option>
</select></div>
<div><label className={styles.label}>Temporary password (12+ chars)</label>
<input className={styles.input} type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={12} /></div>
<div><button className={styles.btn} disabled={busy}>{busy ? 'Adding…' : 'Add instructor'}</button></div>
</div>
</form>
)
}
function InstructorDetail({ instructorId, isSelf, onBack }) {
const [instructor, setInstructor] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const [newPassword, setNewPassword] = useState('')
const reload = useCallback(() => {
adminApi.instructor(instructorId).then(setInstructor).catch((e) => setError(e.message))
}, [instructorId])
useEffect(reload, [reload])
const updateRole = async (role) => {
setBusy(true); setError(null); setNotice(null)
try { await adminApi.updateInstructor(instructorId, { role }); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
const toggleStatus = async () => {
const status = instructor.status === 'active' ? 'inactive' : 'active'
setBusy(true); setError(null); setNotice(null)
try { await adminApi.updateInstructor(instructorId, { status }); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
const revokeSessions = async () => {
setBusy(true); setError(null)
try { await adminApi.revokeInstructorSessions(instructorId); setNotice('All sessions revoked.'); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
const resetPassword = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setNotice(null)
try { await adminApi.resetInstructorPassword(instructorId, newPassword); setNewPassword(''); setNotice('Password reset. All of their sessions were revoked.') }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (!instructor) return error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to instructors</button>
<h2 style={{ margin: '1rem 0' }}>{instructor.display_name} <span className={styles.muted}>({instructor.email})</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Role &amp; status</h3>
<div className={styles.formRow}>
<div><label className={styles.label}>Role</label>
<select className={styles.select} value={instructor.role} onChange={(e) => updateRole(e.target.value)} disabled={busy}>
<option value="instructor">instructor</option>
<option value="admin">admin</option>
<option value="owner">owner</option>
</select></div>
<div><button className={instructor.status === 'active' ? styles.btnDanger : styles.btnSecondary} onClick={toggleStatus} disabled={busy}>
{instructor.status === 'active' ? 'Deactivate' : 'Activate'}
</button></div>
</div>
{isSelf && <p className={styles.muted}>This is your own account — the platform refuses to remove the last active owner, whichever account that is.</p>}
</div>
<div className={styles.card}>
<h3>Sessions</h3>
<p className={styles.muted}>Last login: {instructor.last_login_at ? new Date(instructor.last_login_at).toLocaleString() : 'never'}</p>
{instructor.sessions?.length ? (
<table>
<thead><tr><th>Created</th><th>Last active</th><th>Expires</th><th>Status</th></tr></thead>
<tbody>
{instructor.sessions.map((s) => (
<tr key={s.session_id}>
<td>{new Date(s.created_at).toLocaleString()}</td>
<td>{new Date(s.last_seen_at).toLocaleString()}</td>
<td>{new Date(s.expires_at).toLocaleString()}</td>
<td>{s.revoked_at ? <span className={`${styles.pill} ${styles.pillMuted}`}>revoked</span> : <span className={`${styles.pill} ${styles.pillOk}`}>active</span>}</td>
</tr>
))}
</tbody>
</table>
) : <p className={styles.muted}>No sessions on record.</p>}
<button className={styles.btnDanger} style={{ marginTop: '0.75rem' }} onClick={revokeSessions} disabled={busy}>Revoke all sessions</button>
</div>
<div className={styles.card}>
<h3>Reset password</h3>
<form onSubmit={resetPassword} className={styles.formRow}>
<div><input className={styles.input} type="password" placeholder="New password (12+ chars)" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} minLength={12} required /></div>
<div><button className={styles.btn} disabled={busy}>Set new password</button></div>
</form>
</div>
</>
)
}