From 777a1ef8808864e8524fc70fb531e1e0587028bd Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 18 Aug 2026 13:36:29 -0500 Subject: [PATCH] feat(admin): add instructor management, audit, and account UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/admin/AccountView.jsx | 39 ++++++ frontend/src/admin/AdminPortal.jsx | 67 ++++++--- frontend/src/admin/AuditView.jsx | 38 ++++++ frontend/src/admin/CohortsView.jsx | 39 +++--- frontend/src/admin/InstructorsView.jsx | 180 +++++++++++++++++++++++++ 5 files changed, 327 insertions(+), 36 deletions(-) create mode 100644 frontend/src/admin/AccountView.jsx create mode 100644 frontend/src/admin/AuditView.jsx create mode 100644 frontend/src/admin/InstructorsView.jsx diff --git a/frontend/src/admin/AccountView.jsx b/frontend/src/admin/AccountView.jsx new file mode 100644 index 0000000..62fc567 --- /dev/null +++ b/frontend/src/admin/AccountView.jsx @@ -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 ( +
+

Account

+

{instructor.display_name} · {instructor.email} · role: {instructor.role}

+ +

Change password

+
+ {error &&
{error}
} +
+ setOldPassword(e.target.value)} autoComplete="current-password" required />
+
+ setNewPassword(e.target.value)} autoComplete="new-password" minLength={12} required />
+
+
+ {notice &&

{notice}

} +
+ ) +} diff --git a/frontend/src/admin/AdminPortal.jsx b/frontend/src/admin/AdminPortal.jsx index 30c5263..ec98dc0 100644 --- a/frontend/src/admin/AdminPortal.jsx +++ b/frontend/src/admin/AdminPortal.jsx @@ -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 setAuthed(true)} /> + if (instructor === null) return null + if (!instructor) return + + const visibleNav = NAV.filter((item) => !item.roles || item.roles.includes(instructor.role)) return (
⚙ RootNode Admin
{view === 'dashboard' && } - {view === 'cohorts' && } - {view === 'students' && } + {view === 'cohorts' && } + {view === 'students' && } + {view === 'instructors' && instructor.role === 'owner' && } + {view === 'audit' && ['owner', 'admin'].includes(instructor.role) && } + {view === 'account' && }
) } -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 }) {
⚙ RootNode Admin

Instructor sign-in. Not connected to student accounts.

{error &&
{error}
} - - setPassword(e.target.value)} autoFocus /> - diff --git a/frontend/src/admin/AuditView.jsx b/frontend/src/admin/AuditView.jsx new file mode 100644 index 0000000..cad4510 --- /dev/null +++ b/frontend/src/admin/AuditView.jsx @@ -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 ( +
+

Audit log

+

Most recent 200 security/admin events.

+ {error &&
{error}
} + {!events ?

Loading…

: ( + + + + {events.map((e) => ( + + + + + + + + ))} + +
WhenActorActionResourceResult
{new Date(e.created_at).toLocaleString()}{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}{e.action}{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}{e.result}
+ )} +
+ ) +} diff --git a/frontend/src/admin/CohortsView.jsx b/frontend/src/admin/CohortsView.jsx index 3c13252..b4e1a66 100644 --- a/frontend/src/admin/CohortsView.jsx +++ b/frontend/src/admin/CohortsView.jsx @@ -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 { setSelected(null); reload() }} /> + if (selected) return { setSelected(null); reload() }} /> return ( <> {error &&
{error}
} -
-

Create cohort

- -
+ {canManage && ( +
+

Create cohort

+ +
+ )}

Cohorts

{!cohorts ?

Loading…

: cohorts.length === 0 ?

None yet.

: ( @@ -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 }) {

Roster ({students?.length ?? '…'})

-
-
setNewStudentName(e.target.value)} />
-
-
+ {canManage && ( +
+
setNewStudentName(e.target.value)} />
+
+
+ )} {students && ( @@ -156,11 +161,13 @@ function CohortDetail({ cohortId, onBack }) { )} -
-

Curriculum

-

RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)

- -
+ {canManage && ( +
+

Curriculum

+

RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)

+ +
+ )} {progress && (
diff --git a/frontend/src/admin/InstructorsView.jsx b/frontend/src/admin/InstructorsView.jsx new file mode 100644 index 0000000..8972f91 --- /dev/null +++ b/frontend/src/admin/InstructorsView.jsx @@ -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 { setSelected(null); reload() }} /> + + return ( + <> + {error &&
{error}
} +
+

Add instructor

+ +
+
+

Instructors

+ {!instructors ?

Loading…

: ( +
NameStatusLast active
+ + + {instructors.map((i) => ( + + + + + + + + + ))} + +
NameEmailRoleStatusLast login
{i.display_name}{i.email}{i.role}{i.status}{i.last_login_at ? new Date(i.last_login_at).toLocaleString() : 'never'}
+ )} +
+ + ) +} + +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 ( +
+ {error &&
{error}
} +
+
+ setForm({ ...form, display_name: e.target.value })} required />
+
+ setForm({ ...form, email: e.target.value })} required />
+
+
+
+ setForm({ ...form, password: e.target.value })} required minLength={12} />
+
+
+
+ ) +} + +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 ?
{error}
:

Loading…

+ + return ( + <> + +

{instructor.display_name} ({instructor.email})

+ {error &&
{error}
} + {notice &&

{notice}

} + +
+

Role & status

+
+
+
+
+
+ {isSelf &&

This is your own account — the platform refuses to remove the last active owner, whichever account that is.

} +
+ +
+

Sessions

+

Last login: {instructor.last_login_at ? new Date(instructor.last_login_at).toLocaleString() : 'never'}

+ {instructor.sessions?.length ? ( + + + + {instructor.sessions.map((s) => ( + + + + + + + ))} + +
CreatedLast activeExpiresStatus
{new Date(s.created_at).toLocaleString()}{new Date(s.last_seen_at).toLocaleString()}{new Date(s.expires_at).toLocaleString()}{s.revoked_at ? revoked : active}
+ ) :

No sessions on record.

} + +
+ +
+

Reset password

+
+
setNewPassword(e.target.value)} minLength={12} required />
+
+
+
+ + ) +}