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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user