feat(admin): notification operations — resend activation, delivery status, RBAC-scoped views

- notification-routes.js: global list (owner/admin only), per-student list (scoped via
  requireStudentRelationshipAccess), resend-activation (mechanically the same as
  regenerate — Phase 5A's token model already guarantees only the latest is ever valid, so one
  action covers both), a dev-only preview route (unconditionally refuses when
  COOKIE_SECURE=1, mirroring student-auth-routes.js's existing dev_reset_token gate).
- NotificationsView.jsx: owner/admin global notification list, paginated, matching
  AuditView.jsx's exact convention.
- StudentsView.jsx: student detail gained a Resend activation control and a Notifications
  history card; Credentials card now explains the automatic-email behavior.
- AdminPortal.jsx: new Notifications nav entry (owner/admin only).
This commit is contained in:
2026-08-25 14:43:10 -05:00
parent 3c7f0ca59c
commit f25535027c
5 changed files with 211 additions and 3 deletions
+3
View File
@@ -10,6 +10,7 @@ import LearningView from './LearningView'
import AssessmentsView from './AssessmentsView'
import ClassroomView from './ClassroomView'
import ProgramsView from './ProgramsView'
import NotificationsView from './NotificationsView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
@@ -26,6 +27,7 @@ const NAV = [
{ key: 'classroom', label: 'Classroom' },
{ key: 'learning', label: 'Learning' },
{ key: 'assessments', label: 'Assessments' },
{ key: 'notifications', label: 'Notifications', roles: ['owner', 'admin'] },
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
]
@@ -67,6 +69,7 @@ export default function AdminPortal() {
{view === 'classroom' && <ClassroomView />}
{view === 'learning' && <LearningView />}
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
{view === 'notifications' && ['owner', 'admin'].includes(instructor.role) && <NotificationsView />}
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
{view === 'account' && <AccountView instructor={instructor} />}
+52
View File
@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Phase 5C §16 — owner/admin only (enforced server-side too; a plain instructor's notification
// visibility is per-student, on the Students tab, never this global view — see
// rbac.js's requireStudentRelationshipAccess). No recipient email in full, no token, no
// metadata — everything here comes from notifications.js's toAdminView.
export default function NotificationsView() {
const [page, setPage] = useState(1)
const [result, setResult] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
setResult(null)
adminApi.notifications(page).then(setResult).catch((e) => setError(e.message))
}, [page])
const items = result?.items || []
return (
<div className={styles.card}>
<h2>Notifications</h2>
<p className={styles.muted}>{result ? `${result.total} total` : 'Activation, enrollment, and password-reset emails — most recent first.'}</p>
{error && <div className={styles.errorBox}>{error}</div>}
{!result ? <p className={styles.muted}>Loading…</p> : (
<>
<table>
<thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Attempts</th><th>Error</th></tr></thead>
<tbody>
{items.map((n) => (
<tr key={n.notification_id}>
<td>{new Date(n.created_at).toLocaleString()}</td>
<td>{n.notification_type.replace(/_/g, ' ')}</td>
<td className={styles.muted}>{n.recipient_email_masked || '—'}</td>
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
<td className={styles.muted}>{n.attempt_count}</td>
<td className={styles.muted}>{n.last_error_code || '—'}</td>
</tr>
))}
</tbody>
</table>
<div className={styles.pager}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>
<span className={styles.muted}>Page {result.page} of {result.total_pages}</span>
<button disabled={page >= result.total_pages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</>
)}
</div>
)
}
+32 -3
View File
@@ -168,6 +168,7 @@ function StudentDetail({ studentId, canManage, onBack }) {
const [events, setEvents] = useState(null)
const [sessions, setSessions] = useState(null)
const [enrollments, setEnrollments] = useState(null)
const [notifications, setNotifications] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
@@ -184,6 +185,8 @@ function StudentDetail({ studentId, canManage, onBack }) {
// Phase 5B §11 — scoped server-side the same way as everything else here: a plain
// instructor only sees enrollments they created or that share a cohort with them.
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
// Phase 5C §16 — same scoping rule (rbac.js's requireStudentRelationshipAccess).
adminApi.studentNotifications(studentId).then(setNotifications).catch(() => {})
}, [studentId])
useEffect(reload, [reload])
@@ -209,21 +212,47 @@ function StudentDetail({ studentId, canManage, onBack }) {
<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>
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''} An email is sent automatically wherever possible — see Notifications below.</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>
setNotice(`Password reset. Link (shown once, for manual relay if email can't reach them): ${window.location.origin}/?reset=${reset_token}`)
})}>Send password reset</button>
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
</div>
</>
) : student?.email || student?.login_identifier ? (
<>
<p className={styles.muted}>Not yet activated. An activation link was queued — use this if the student never got it or it expired.</p>
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.resendActivation(studentId))}>Resend activation</button>
</>
) : (
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
)}
</div>
)}
{canManage && (
<div className={styles.card}>
<h3>Notifications</h3>
{!notifications ? <p className={styles.muted}>Loading…</p> : notifications.length === 0 ? <p className={styles.muted}>None yet.</p> : (
<table>
<thead><tr><th>When</th><th>Type</th><th>Status</th><th>Attempts</th></tr></thead>
<tbody>
{notifications.map((n) => (
<tr key={n.notification_id}>
<td>{new Date(n.created_at).toLocaleString()}</td>
<td>{n.notification_type.replace(/_/g, ' ')}</td>
<td><span className={`${styles.pill} ${n.status === 'sent' ? styles.pillOk : n.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{n.status}</span></td>
<td className={styles.muted}>{n.attempt_count}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
<div className={styles.card}>
<h3>Environment</h3>
{!env ? <p className={styles.muted}>No Lab provisioned.</p> : (
+6
View File
@@ -128,4 +128,10 @@ export const adminApi = {
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`),
// ── Phase 5C §15/§16: enrollment operations & communications ────────────────────────────
notifications: (page = 1) => get(`/api/admin/notifications?page=${page}`),
studentNotifications: (studentId) => get(`/api/admin/students/${studentId}/notifications`),
resendActivation: (studentId) => post(`/api/admin/students/${studentId}/resend-activation`),
sendEnrollmentConfirmation: (studentId, enrollmentId) => post(`/api/admin/students/${studentId}/send-enrollment-confirmation`, enrollmentId ? { enrollment_id: enrollmentId } : undefined),
}