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