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.
181 lines
8.5 KiB
React
181 lines
8.5 KiB
React
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}>← 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 & 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>
|
|
</>
|
|
)
|
|
}
|