From 7557fdc7531fadd540dfb4b92493f7321fe8dfbf Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 01:19:54 -0500 Subject: [PATCH] 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. --- frontend/src/admin/AdminPortal.jsx | 3 + frontend/src/admin/ClassroomView.jsx | 216 ++++++++++++++++++++++++++ frontend/src/admin/CohortsView.jsx | 2 +- frontend/src/admin/StudentsView.jsx | 221 ++++++++++++++++++++++++--- frontend/src/admin/api.js | 25 ++- 5 files changed, 443 insertions(+), 24 deletions(-) create mode 100644 frontend/src/admin/ClassroomView.jsx diff --git a/frontend/src/admin/AdminPortal.jsx b/frontend/src/admin/AdminPortal.jsx index 6efd3d5..a521ad9 100644 --- a/frontend/src/admin/AdminPortal.jsx +++ b/frontend/src/admin/AdminPortal.jsx @@ -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' && } {view === 'cohorts' && } {view === 'students' && } + {view === 'classroom' && } {view === 'learning' && } {view === 'assessments' && } {view === 'instructors' && instructor.role === 'owner' && } diff --git a/frontend/src/admin/ClassroomView.jsx b/frontend/src/admin/ClassroomView.jsx new file mode 100644 index 0000000..1c915ed --- /dev/null +++ b/frontend/src/admin/ClassroomView.jsx @@ -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 { setSelected(null); reload() }} /> + + return ( + <> + {error &&
{error}
} +
+

Classroom

+
+
+
+
+
+ + {cohortId && ( +
+

Schedule a class

+ +
+ )} + +
+

Classes

+ {!classrooms ?

Loading…

: classrooms.length === 0 ?

None yet.

: ( + + + + {classrooms.map((c) => ( + + + + + + + ))} + +
TitleStatusScheduled
{c.title}{c.status}{c.scheduled_start ? new Date(c.scheduled_start).toLocaleString() : '—'}
+ )} +
+ + ) +} + +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 ( +
+ {error &&
{error}
} +
+
+ setTitle(e.target.value)} required />
+
+ setScheduledStart(e.target.value)} />
+
+ setMeetingUrl(e.target.value)} />
+
+ + setDescription(e.target.value)} /> + +
+ ) +} + +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 ?
{error}
:

Loading…

} + const { classroom, students, announcements, help_requests: helpRequests } = data + const openHelp = helpRequests.filter((h) => h.status !== 'resolved') + + return ( + <> + +
+

{classroom.title} {classroom.status}

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

{notice}

} +
+ {classroom.status === 'scheduled' && } + {classroom.status === 'live' && } + {classroom.status === 'scheduled' && } + +
+
+ +
+

Participants ({students.length})

+ {students.length === 0 ?

No one enrolled in this cohort yet.

: ( + + + + {students.map((s) => ( + + + + + + + + ))} + +
StudentPresenceLabCurrent workHints
{s.display_name}{s.presence}{s.lab_status}{s.current_scenario || '—'} {s.attempt_status ? `(${s.attempt_status}, ${s.validation_failures} fails)` : ''}{s.hints_used}
+ )} +
+ +
+

Help queue {openHelp.length > 0 && {openHelp.length} open}

+ {helpRequests.length === 0 ?

No help requests.

: ( + + + + {helpRequests.map((h) => ( + + + + + + + + ))} + +
StudentCategoryMessageStatus
{h.display_name || h.student_id}{h.category}{h.message || '—'}{h.status} + {h.status === 'open' && } + {h.status !== 'resolved' && } +
+ )} +
+ +
+

Current activity

+

Guidance only — students see this as a link, nothing auto-redirects them (§20).

+
+
setActivityScenario(e.target.value)} />
+
+
+
+ +
+

Announcements

+ {announcements.length === 0 ?

None yet.

: ( +
    {announcements.map((a) =>
  • {a.message}
  • )}
+ )} +
+
setAnnouncement(e.target.value)} />
+
+
+
+ + ) +} diff --git a/frontend/src/admin/CohortsView.jsx b/frontend/src/admin/CohortsView.jsx index b4e1a66..77dd587 100644 --- a/frontend/src/admin/CohortsView.jsx +++ b/frontend/src/admin/CohortsView.jsx @@ -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) } diff --git a/frontend/src/admin/StudentsView.jsx b/frontend/src/admin/StudentsView.jsx index e60b83b..934d7fa 100644 --- a/frontend/src/admin/StudentsView.jsx +++ b/frontend/src/admin/StudentsView.jsx @@ -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 { setSelected(null); reload() }} /> + if (selected) return { setSelected(null); reload() }} /> return ( -
-

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

+ <> {error &&
{error}
} - {students && ( - - - - {students.map((s) => ( - - - - - - - - ))} - -
NameIDStatusLast active
{s.display_name}{s.student_id}{s.status}{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}
+ {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 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 ( +
+ {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}
+ ))} +
+ )} +