import { useState, useEffect, useCallback } from 'react' import { adminApi } from './api' import { StatusPill } from './CohortsView' import styles from './Admin.module.css' // Phase 5A §4-7: student roster now shows credential status alongside the pilot fields that // were already here. canManage gates create/convert/reset/revoke/import the same way // CohortsView gates cohort management — the server (requireAdminOrOwner) is the real boundary, // this just avoids showing controls a plain instructor would get a 403 from. export default function StudentsView({ role }) { const [students, setStudents] = useState(null) const [selected, setSelected] = useState(null) const [error, setError] = useState(null) const canManage = role !== 'instructor' const reload = useCallback(() => { adminApi.students().then(setStudents).catch((e) => setError(e.message)) }, []) useEffect(reload, [reload]) if (selected) return { setSelected(null); reload() }} /> return ( <> {error &&
{error}
} {canManage && (

Add student

)} {canManage && (

Bulk import (CSV)

)}

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

{students && ( {students.map((s) => ( ))}
NameIDLoginStatusLast active
{s.display_name} {s.student_id} {s.has_credentials ? {s.email || s.login_identifier} : pilot (no login)} {s.status} {s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}
)}
) } function CreateStudentForm({ onCreated }) { const [displayName, setDisplayName] = useState('') const [email, setEmail] = useState('') const [loginIdentifier, setLoginIdentifier] = useState('') const [cohortId, setCohortId] = useState('') const [cohorts, setCohorts] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [created, setCreated] = useState(null) // { student_id, activation_token } — token shown exactly once useEffect(() => { adminApi.cohorts().then(setCohorts).catch(() => {}) }, []) const submit = async (e) => { e.preventDefault() setBusy(true); setError(null); setCreated(null) try { const result = await adminApi.createStudent({ display_name: displayName.trim(), cohort_id: cohortId || undefined, email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined, }) setCreated(result) setDisplayName(''); setEmail(''); setLoginIdentifier('') onCreated() } catch (e) { setError(e.message) } finally { setBusy(false) } } return (
{error &&
{error}
} {created?.activation_token && (
Account created ({created.student_id}). Activation link (share it out of band, shown once):
{`${window.location.origin}/?activate=${created.activation_token}`}
)}
setDisplayName(e.target.value)} required />
setEmail(e.target.value)} />
setLoginIdentifier(e.target.value)} />

Leave email and login ID both blank to add a pilot (no-login) student.

) } function ImportForm({ onImported }) { const [csv, setCsv] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [rowErrors, setRowErrors] = useState(null) const [created, setCreated] = useState(null) const submit = async (e) => { e.preventDefault() setBusy(true); setError(null); setRowErrors(null); setCreated(null) try { const { created: rows } = await adminApi.importStudents(csv) setCreated(rows); setCsv('') onImported() } catch (e) { if (e.row_errors) setRowErrors(e.row_errors) // not thrown this way by the json() helper today; message covers it below setError(e.message) } finally { setBusy(false) } } return (

Columns: display_name, email, login_identifier, cohort_id (email/login_identifier/cohort_id optional per row). Never include a password column — the whole file is validated before anything is imported.

{error &&
{error}
} {rowErrors && (
{rowErrors.map((re, i) =>
{re}
)}
)} {created && (
Imported {created.length} student(s). Activation links (shown once — copy before navigating away): {created.filter((c) => c.activation_token).map((c) => (
{c.display_name}: {window.location.origin}/?activate={c.activation_token}
))}
)}