feat(admin): student credential management, CSV import UI, remote classroom control center

- StudentsView: credential status in the roster table, a create-student form
  (email/login-ID/cohort), convert/reset-password/revoke-sessions controls per student, a
  bulk CSV import form. Every one-time activation/reset link is surfaced exactly once, never
  re-fetchable.
- ClassroomView: cohort picker -> schedule/list classes -> a live dashboard (participants with
  presence/Lab-status/current-work, help queue with acknowledge/resolve, current-activity
  setter, announcement poster, start/end/cancel, Prepare Labs). Polls the dashboard endpoint
  rather than a second push channel.
- AdminPortal: new Classroom nav entry. CohortsView's existing quick-add-student call updated
  for createStudent's new object-argument signature.
This commit is contained in:
2026-08-25 01:19:54 -05:00
parent 5c7e49c399
commit 7557fdc753
5 changed files with 443 additions and 24 deletions
+3
View File
@@ -8,6 +8,7 @@ import AuditView from './AuditView'
import AccountView from './AccountView'
import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import ClassroomView from './ClassroomView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
@@ -20,6 +21,7 @@ const NAV = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
@@ -59,6 +61,7 @@ export default function AdminPortal() {
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />}
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
+216
View File
@@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
const DASHBOARD_POLL_MS = 5000
// Phase 5A §14/§21 — remote classroom control center. Cohort picker -> that cohort's
// classroom sessions -> a live dashboard once one is open. No video/chat/shell-takeover here
// by design (§37) — this is identity/presence/Lab-status/help-queue/announcements only.
export default function ClassroomView() {
const [cohorts, setCohorts] = useState(null)
const [cohortId, setCohortId] = useState('')
const [classrooms, setClassrooms] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { adminApi.cohorts().then((rows) => { setCohorts(rows); if (rows.length) setCohortId(rows[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
const reload = useCallback(() => {
if (!cohortId) return
adminApi.classrooms(cohortId).then(setClassrooms).catch((e) => setError(e.message))
}, [cohortId])
useEffect(reload, [reload])
if (selected) return <ClassroomDashboard classroomId={selected} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h2>Classroom</h2>
<div className={styles.formRow}>
<div><label className={styles.label}>Cohort</label>
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select></div>
</div>
</div>
{cohortId && (
<div className={styles.card}>
<h3>Schedule a class</h3>
<CreateClassroomForm cohortId={cohortId} onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h3>Classes</h3>
{!classrooms ? <p className={styles.muted}>Loading…</p> : classrooms.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>Title</th><th>Status</th><th>Scheduled</th><th /></tr></thead>
<tbody>
{classrooms.map((c) => (
<tr key={c.classroom_session_id}>
<td>{c.title}</td>
<td><span className={`${styles.pill} ${c.status === 'live' ? styles.pillOk : c.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{c.status}</span></td>
<td>{c.scheduled_start ? new Date(c.scheduled_start).toLocaleString() : '—'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(c.classroom_session_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateClassroomForm({ cohortId, onCreated }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [scheduledStart, setScheduledStart] = useState('')
const [meetingUrl, setMeetingUrl] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createClassroom(cohortId, {
title: title.trim(), description: description.trim() || undefined,
scheduled_start: scheduledStart || undefined, meeting_url: meetingUrl.trim() || undefined,
})
setTitle(''); setDescription(''); setScheduledStart(''); setMeetingUrl('')
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}>Title</label>
<input className={styles.input} value={title} onChange={(e) => setTitle(e.target.value)} required /></div>
<div><label className={styles.label}>Scheduled start</label>
<input className={styles.input} type="datetime-local" value={scheduledStart} onChange={(e) => setScheduledStart(e.target.value)} /></div>
<div><label className={styles.label}>Meeting URL (Meet/Zoom/Teams — optional)</label>
<input className={styles.input} value={meetingUrl} onChange={(e) => setMeetingUrl(e.target.value)} /></div>
</div>
<label className={styles.label}>Description (optional)</label>
<input className={styles.input} value={description} onChange={(e) => setDescription(e.target.value)} />
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy || !title.trim()}>{busy ? 'Scheduling…' : 'Schedule'}</button>
</form>
)
}
function ClassroomDashboard({ classroomId, onBack }) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const [announcement, setAnnouncement] = useState('')
const [activityScenario, setActivityScenario] = useState('')
const reload = useCallback(() => {
adminApi.classroomDashboard(classroomId).then(setData).catch((e) => setError(e.message))
}, [classroomId])
useEffect(() => {
reload()
const t = setInterval(reload, DASHBOARD_POLL_MS) // presence/help-queue/Lab-status all move live — poll rather than a second push channel
return () => clearInterval(t)
}, [reload])
const withBusy = (fn) => async () => {
setBusy(true); setError(null); setNotice(null)
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (!data) return <>{error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>}</>
const { classroom, students, announcements, help_requests: helpRequests } = data
const openHelp = helpRequests.filter((h) => h.status !== 'resolved')
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to classes</button>
<div className={styles.card} style={{ marginTop: '1rem' }}>
<h2>{classroom.title} <span className={`${styles.pill} ${classroom.status === 'live' ? styles.pillOk : styles.pillMuted}`}>{classroom.status}</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.formRow}>
{classroom.status === 'scheduled' && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.startClassroom(classroomId))}>Start class</button>}
{classroom.status === 'live' && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.endClassroom(classroomId))}>End class</button>}
{classroom.status === 'scheduled' && <button className={styles.btnDanger} disabled={busy} onClick={withBusy(() => adminApi.cancelClassroom(classroomId))}>Cancel</button>}
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
const { results } = await adminApi.prepareClassroomLabs(classroomId)
setNotice(`Labs prepared: ${results.length} student(s).`)
})}>Prepare Labs</button>
</div>
</div>
<div className={styles.card}>
<h3>Participants ({students.length})</h3>
{students.length === 0 ? <p className={styles.muted}>No one enrolled in this cohort yet.</p> : (
<table>
<thead><tr><th>Student</th><th>Presence</th><th>Lab</th><th>Current work</th><th>Hints</th></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</td>
<td><span className={`${styles.pill} ${s.presence === 'online' ? styles.pillOk : s.presence === 'recently_active' ? styles.pillWarn : styles.pillMuted}`}>{s.presence}</span></td>
<td><span className={`${styles.pill} ${s.lab_status === 'running' ? styles.pillOk : s.lab_status === 'error' ? styles.pillErr : styles.pillMuted}`}>{s.lab_status}</span></td>
<td className={styles.muted}>{s.current_scenario || '—'} {s.attempt_status ? `(${s.attempt_status}, ${s.validation_failures} fails)` : ''}</td>
<td className={styles.muted}>{s.hints_used}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Help queue {openHelp.length > 0 && <span className={`${styles.pill} ${styles.pillWarn}`}>{openHelp.length} open</span>}</h3>
{helpRequests.length === 0 ? <p className={styles.muted}>No help requests.</p> : (
<table>
<thead><tr><th>Student</th><th>Category</th><th>Message</th><th>Status</th><th /></tr></thead>
<tbody>
{helpRequests.map((h) => (
<tr key={h.help_request_id}>
<td>{h.display_name || h.student_id}</td>
<td className={styles.muted}>{h.category}</td>
<td className={styles.muted}>{h.message || '—'}</td>
<td><span className={`${styles.pill} ${h.status === 'resolved' ? styles.pillOk : h.status === 'acknowledged' ? styles.pillWarn : styles.pillErr}`}>{h.status}</span></td>
<td>
{h.status === 'open' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.acknowledgeHelpRequest(h.help_request_id))}>Acknowledge</button>}
{h.status !== 'resolved' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.resolveHelpRequest(h.help_request_id))}>Resolve</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}>
<h3>Current activity</h3>
<p className={styles.muted}>Guidance only — students see this as a link, nothing auto-redirects them (§20).</p>
<div className={styles.formRow}>
<div><input className={styles.input} placeholder="scenario id" value={activityScenario} onChange={(e) => setActivityScenario(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || !activityScenario.trim()} onClick={withBusy(async () => { await adminApi.setClassroomActivity(classroomId, activityScenario.trim()); setActivityScenario('') })}>Set</button></div>
</div>
</div>
<div className={styles.card}>
<h3>Announcements</h3>
{announcements.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<ul>{announcements.map((a) => <li key={a.announcement_id} className={styles.muted}>{a.message}</li>)}</ul>
)}
<div className={styles.formRow} style={{ marginTop: '0.5rem' }}>
<div><input className={styles.input} placeholder="Post an announcement" value={announcement} onChange={(e) => setAnnouncement(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || !announcement.trim()} onClick={withBusy(async () => { await adminApi.postClassroomAnnouncement(classroomId, announcement.trim()); setAnnouncement('') })}>Post</button></div>
</div>
</div>
</>
)
}
+1 -1
View File
@@ -101,7 +101,7 @@ function CohortDetail({ cohortId, canManage, onBack }) {
if (!newStudentName.trim()) return
setBusy(true); setError(null)
try {
await adminApi.createStudent(newStudentName.trim(), cohortId)
await adminApi.createStudent({ display_name: newStudentName.trim(), cohort_id: cohortId })
setNewStudentName('')
reload()
} catch (e) { setError(e.message) } finally { setBusy(false) }
+200 -21
View File
@@ -3,57 +3,183 @@ import { adminApi } from './api'
import { StatusPill } from './CohortsView'
import styles from './Admin.module.css'
export default function StudentsView() {
// 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 <StudentDetail studentId={selected} onBack={() => { setSelected(null); reload() }} />
if (selected) return <StudentDetail studentId={selected} canManage={canManage} onBack={() => { setSelected(null); reload() }} />
return (
<div className={styles.card}>
<h2>Students ({students?.length ?? '…'})</h2>
<>
{error && <div className={styles.errorBox}>{error}</div>}
{students && (
<table>
<thead><tr><th>Name</th><th>ID</th><th>Status</th><th>Last active</th><th /></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</td>
<td className={styles.muted}>{s.student_id}</td>
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
{canManage && (
<div className={styles.card}>
<h2>Add student</h2>
<CreateStudentForm onCreated={reload} />
</div>
)}
</div>
{canManage && (
<div className={styles.card}>
<h2>Bulk import (CSV)</h2>
<ImportForm onImported={reload} />
</div>
)}
<div className={styles.card}>
<h2>Students ({students?.length ?? '…'})</h2>
{students && (
<table>
<thead><tr><th>Name</th><th>ID</th><th>Login</th><th>Status</th><th>Last active</th><th /></tr></thead>
<tbody>
{students.map((s) => (
<tr key={s.student_id}>
<td>{s.display_name}</td>
<td className={styles.muted}>{s.student_id}</td>
<td>
{s.has_credentials
? <span className={`${styles.pill} ${styles.pillOk}`}>{s.email || s.login_identifier}</span>
: <span className={`${styles.pill} ${styles.pillMuted}`}>pilot (no login)</span>}
</td>
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function StudentDetail({ studentId, onBack }) {
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 (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
{created?.activation_token && (
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
Account created ({created.student_id}). Activation link (share it out of band, shown once):<br />
<code>{`${window.location.origin}/?activate=${created.activation_token}`}</code>
</div>
)}
<div className={styles.formRow}>
<div><label className={styles.label}>Display name</label>
<input className={styles.input} value={displayName} onChange={(e) => setDisplayName(e.target.value)} required /></div>
<div><label className={styles.label}>Email (older/self-paced)</label>
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div><label className={styles.label}>Login ID (younger/managed)</label>
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
<div><label className={styles.label}>Cohort (optional)</label>
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
<option value="">— none —</option>
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
</select></div>
</div>
<p className={styles.muted}>Leave email and login ID both blank to add a pilot (no-login) student.</p>
<button className={styles.btn} disabled={busy || !displayName.trim()}>{busy ? 'Adding…' : 'Add student'}</button>
</form>
)
}
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 (
<form onSubmit={submit}>
<p className={styles.muted}>Columns: <code>display_name, email, login_identifier, cohort_id</code> (email/login_identifier/cohort_id optional per row). Never include a password column — the whole file is validated before anything is imported.</p>
{error && <div className={styles.errorBox}>{error}</div>}
{rowErrors && (
<div className={styles.errorBox}>{rowErrors.map((re, i) => <div key={i}>{re}</div>)}</div>
)}
{created && (
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
Imported {created.length} student(s). Activation links (shown once — copy before navigating away):
{created.filter((c) => c.activation_token).map((c) => (
<div key={c.student_id}><code>{c.display_name}: {window.location.origin}/?activate={c.activation_token}</code></div>
))}
</div>
)}
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '6rem' }}
placeholder={'display_name,email,login_identifier,cohort_id\nJane Doe,[email protected],,'}
value={csv} onChange={(e) => setCsv(e.target.value)} required />
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy || !csv.trim()}>{busy ? 'Importing…' : 'Import'}</button>
</form>
)
}
function StudentDetail({ studentId, canManage, onBack }) {
const [student, setStudent] = useState(null)
const [progress, setProgress] = useState(null)
const [attempts, setAttempts] = useState(null)
const [env, setEnv] = useState(null)
const [events, setEvents] = useState(null)
const [sessions, setSessions] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const reload = useCallback(() => {
adminApi.students().then((rows) => setStudent(rows.find((s) => s.student_id === studentId) || null)).catch(() => {})
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
// Phase 4.1 §16 — studentAttempts is now paginated; this panel only ever shows the most
// recent page (labeled "Recent attempts," not a full history browser — see
// /api/admin/students/:id/attempts's own pagination for the underlying capability).
adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {})
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
adminApi.studentSessions(studentId).then(setSessions).catch(() => {})
}, [studentId])
useEffect(reload, [reload])
@@ -72,6 +198,27 @@ function StudentDetail({ studentId, onBack }) {
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to students</button>
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
{canManage && (
<div className={styles.card}>
<h3>Credentials</h3>
{student?.has_credentials ? (
<>
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''}</p>
<div className={styles.formRow}>
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
const { reset_token } = await adminApi.resetStudentPassword(studentId)
setNotice(`Password reset. Link (shown once): ${window.location.origin}/?reset=${reset_token}`)
})}>Reset password</button>
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
</div>
</>
) : (
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
)}
</div>
)}
<div className={styles.card}>
<h3>Environment</h3>
@@ -134,3 +281,35 @@ function StudentDetail({ studentId, onBack }) {
</>
)
}
function ConvertForm({ studentId, onDone }) {
const [email, setEmail] = useState('')
const [loginIdentifier, setLoginIdentifier] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const { activation_token } = await adminApi.convertStudent(studentId, {
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
})
onDone(`Converted. Activation link (shown once): ${window.location.origin}/?activate=${activation_token}`)
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
<p className={styles.muted}>Still a pilot account (no login). Give it an email or login ID to convert it to a real, password-protected account.</p>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Email</label>
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div><label className={styles.label}>Login ID</label>
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy || (!email.trim() && !loginIdentifier.trim())}>{busy ? 'Converting…' : 'Convert'}</button></div>
</div>
</form>
)
}
+23 -2
View File
@@ -16,7 +16,9 @@ function getCookie(name) {
async function json(res) {
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || body.error || `Request failed (${res.status})`)
const err = new Error(body.message || body.error || `Request failed (${res.status})`)
if (body.row_errors) err.row_errors = body.row_errors // Phase 5A §29 — CSV import row-level errors
throw err
}
return res.json()
}
@@ -49,8 +51,14 @@ export const adminApi = {
dashboard: () => get('/api/admin/dashboard'),
students: () => get('/api/admin/students'),
createStudent: (display_name, cohort_id) => post('/api/admin/students', { display_name, cohort_id }),
createStudent: (fields) => post('/api/admin/students', fields),
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
// ── Phase 5A §4-7/§29: credential lifecycle + CSV import ────────────────────────────────
convertStudent: (id, fields) => post(`/api/admin/students/${id}/convert`, fields),
resetStudentPassword: (id) => post(`/api/admin/students/${id}/reset-password`),
revokeStudentSessions: (id) => post(`/api/admin/students/${id}/revoke-sessions`),
studentSessions: (id) => get(`/api/admin/students/${id}/sessions`),
importStudents: (csv) => post('/api/admin/students/import', { csv }),
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
studentAttempts: (id, page = 1) => get(`/api/admin/students/${id}/attempts?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
studentEnvironment: (id) => get(`/api/admin/students/${id}/environment`),
@@ -95,4 +103,17 @@ export const adminApi = {
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
assessmentSessions: (id, page = 1) => get(`/api/admin/assessments/${id}/sessions?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
// ── Phase 5A §14/§21: remote classroom control center ───────────────────────────────────
classrooms: (cohortId) => get(`/api/admin/cohorts/${cohortId}/classroom-sessions`),
createClassroom: (cohortId, fields) => post(`/api/admin/cohorts/${cohortId}/classroom-sessions`, fields),
classroomDashboard: (id) => get(`/api/admin/classroom-sessions/${id}`),
startClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/start`),
endClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/end`),
cancelClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/cancel`),
prepareClassroomLabs: (id) => post(`/api/admin/classroom-sessions/${id}/prepare-labs`),
setClassroomActivity: (id, scenario_id) => post(`/api/admin/classroom-sessions/${id}/activity`, { scenario_id }),
postClassroomAnnouncement: (id, message) => post(`/api/admin/classroom-sessions/${id}/announcements`, { message }),
acknowledgeHelpRequest: (id) => post(`/api/admin/help-requests/${id}/acknowledge`),
resolveHelpRequest: (id, note) => post(`/api/admin/help-requests/${id}/resolve`, { note }),
}