From f25535027c0023b82f78c45bf7cff2ae7e4d65d4 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 14:43:10 -0500 Subject: [PATCH] =?UTF-8?q?feat(admin):=20notification=20operations=20?= =?UTF-8?q?=E2=80=94=20resend=20activation,=20delivery=20status,=20RBAC-sc?= =?UTF-8?q?oped=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- frontend/src/admin/AdminPortal.jsx | 3 + frontend/src/admin/NotificationsView.jsx | 52 ++++++++++ frontend/src/admin/StudentsView.jsx | 35 ++++++- frontend/src/admin/api.js | 6 ++ platform/api/src/notification-routes.js | 118 +++++++++++++++++++++++ 5 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 frontend/src/admin/NotificationsView.jsx create mode 100644 platform/api/src/notification-routes.js diff --git a/frontend/src/admin/AdminPortal.jsx b/frontend/src/admin/AdminPortal.jsx index e10621e..9d286ad 100644 --- a/frontend/src/admin/AdminPortal.jsx +++ b/frontend/src/admin/AdminPortal.jsx @@ -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' && } {view === 'learning' && } {view === 'assessments' && } + {view === 'notifications' && ['owner', 'admin'].includes(instructor.role) && } {view === 'instructors' && instructor.role === 'owner' && } {view === 'audit' && ['owner', 'admin'].includes(instructor.role) && } {view === 'account' && } diff --git a/frontend/src/admin/NotificationsView.jsx b/frontend/src/admin/NotificationsView.jsx new file mode 100644 index 0000000..7c60974 --- /dev/null +++ b/frontend/src/admin/NotificationsView.jsx @@ -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 ( +
+

Notifications

+

{result ? `${result.total} total` : 'Activation, enrollment, and password-reset emails — most recent first.'}

+ {error &&
{error}
} + {!result ?

Loading…

: ( + <> + + + + {items.map((n) => ( + + + + + + + + + ))} + +
WhenTypeRecipientStatusAttemptsError
{new Date(n.created_at).toLocaleString()}{n.notification_type.replace(/_/g, ' ')}{n.recipient_email_masked || '—'}{n.status}{n.attempt_count}{n.last_error_code || '—'}
+
+ + Page {result.page} of {result.total_pages} + +
+ + )} +
+ ) +} diff --git a/frontend/src/admin/StudentsView.jsx b/frontend/src/admin/StudentsView.jsx index b8c694a..d88f11e 100644 --- a/frontend/src/admin/StudentsView.jsx +++ b/frontend/src/admin/StudentsView.jsx @@ -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 }) {

Credentials

{student?.has_credentials ? ( <> -

Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''}

+

Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''} An email is sent automatically wherever possible — see Notifications below.

+ setNotice(`Password reset. Link (shown once, for manual relay if email can't reach them): ${window.location.origin}/?reset=${reset_token}`) + })}>Send password reset
+ ) : student?.email || student?.login_identifier ? ( + <> +

Not yet activated. An activation link was queued — use this if the student never got it or it expired.

+ + ) : ( { setNotice(msg); reload() }} /> )} )} + {canManage && ( +
+

Notifications

+ {!notifications ?

Loading…

: notifications.length === 0 ?

None yet.

: ( + + + + {notifications.map((n) => ( + + + + + + + ))} + +
WhenTypeStatusAttempts
{new Date(n.created_at).toLocaleString()}{n.notification_type.replace(/_/g, ' ')}{n.status}{n.attempt_count}
+ )} +
+ )} +

Environment

{!env ?

No Lab provisioned.

: ( diff --git a/frontend/src/admin/api.js b/frontend/src/admin/api.js index fc2d812..89af425 100644 --- a/frontend/src/admin/api.js +++ b/frontend/src/admin/api.js @@ -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), } diff --git a/platform/api/src/notification-routes.js b/platform/api/src/notification-routes.js new file mode 100644 index 0000000..f9a2074 --- /dev/null +++ b/platform/api/src/notification-routes.js @@ -0,0 +1,118 @@ +// Phase 5C §15/§16 — instructor/admin communication operations. Global notification visibility +// is owner/admin only (§16: "Do not grant global notification visibility to every instructor"); +// a plain instructor can still see/act on notifications for a SPECIFIC student they're +// authorized to manage via rbac.js's requireStudentRelationshipAccess (cohort-shared or an +// enrollment they created — the same rule admin-programs-routes.js already established for +// enrollment visibility). Every response here goes through notifications.js's toAdminView, +// which masks the recipient address and omits `metadata` entirely — no token, no full email, +// ever, through any route in this file. + +const notificationsLib = require('./notifications'); +const enrollmentLib = require('./enrollment'); +const programsLib = require('./programs'); +const studentsAuth = require('./students-auth'); +const { parsePagination, paginatedResponse } = require('./pagination'); + +module.exports = function registerNotificationRoutes(app, deps) { + const { instructorSession, rbac, getPool, recordAudit } = deps; + const { requireAdminOrOwner, requireStudentRelationshipAccess } = rbac; + + function wrap(fn) { + return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; + } + const admin = { + get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)), + post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)), + }; + + // Global operational view — owner/admin only. + admin.get('/api/admin/notifications', requireAdminOrOwner, async (req, res) => { + const pool = getPool(); + const { page, pageSize, offset } = parsePagination(req); + const [{ rows }, { rows: countRows }] = await Promise.all([ + pool.query(`SELECT * FROM notification_events ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset]), + pool.query(`SELECT count(*)::int AS n FROM notification_events`), + ]); + res.json(paginatedResponse(rows.map(notificationsLib.toAdminView), countRows[0].n, { page, pageSize })); + }); + + // Per-student view — the scoped surface a plain instructor can actually reach. + admin.get('/api/admin/students/:id/notifications', requireStudentRelationshipAccess('id'), async (req, res) => { + const rows = await notificationsLib.listForStudent(getPool(), req.params.id); + res.json(rows.map(notificationsLib.toAdminView)); + }); + + // §20 — dev/test ONLY: lets a test script (or a developer) see exactly what the dev provider + // would have sent, including the activation/reset URL, without a real inbox. Unconditionally + // refuses in production regardless of what's stored (mirrors student-auth-routes.js's + // dev_reset_token gate on the same COOKIE_SECURE-derived signal) — this is never how an + // admin inspects a real delivery, only how a dev/CI run verifies one. + admin.get('/api/admin/notifications/:id/dev-preview', requireAdminOrOwner, async (req, res) => { + if (process.env.COOKIE_SECURE === '1') return res.status(404).json({ error: 'not_found' }); + const preview = await notificationsLib.getDevPreview(getPool(), req.params.id); + if (!preview) return res.status(404).json({ error: 'no_preview_available' }); + res.json(preview); + }); + + // §15 — one action covers both "resend" and "regenerate": Phase 5A's issueCredentialToken + // already unconditionally invalidates any previous outstanding token on mint, so there is + // never a moment with two simultaneously valid activation links — a second button that did + // something different would have nothing different to do. See + // docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6 for the full reasoning. + admin.post('/api/admin/students/:id/resend-activation', requireStudentRelationshipAccess('id'), async (req, res) => { + const pool = getPool(); + const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + if (rows[0].password_hash) return res.status(409).json({ error: 'already_activated', message: 'This student already has real credentials — use Send Password Reset instead.' }); + + const { rows: enrollmentRows } = await pool.query( + `SELECT enrollment_id, program_id FROM enrollments WHERE student_id = $1 ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id] + ); + const enrollmentId = enrollmentRows[0]?.enrollment_id || null; + const program = enrollmentRows[0] ? await programsLib.getProgram(pool, enrollmentRows[0].program_id) : null; + + const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, { + studentId: req.params.id, enrollmentId, programName: program?.name, isResend: true, + }); + await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'activation_resent', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); + res.json(notificationsLib.toAdminView(notification)); + }); + + admin.post('/api/admin/students/:id/send-enrollment-confirmation', requireStudentRelationshipAccess('id'), async (req, res) => { + const pool = getPool(); + const { enrollment_id } = req.body || {}; + const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_activated', message: 'This student has no credentials yet — use Resend Activation instead.' }); + + let targetEnrollmentId = enrollment_id; + if (!targetEnrollmentId) { + const { rows: latest } = await pool.query( + `SELECT enrollment_id FROM enrollments WHERE student_id = $1 AND status = 'active' ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id] + ); + targetEnrollmentId = latest[0]?.enrollment_id; + } + if (!targetEnrollmentId) return res.status(400).json({ error: 'no_active_enrollment', message: 'This student has no active enrollment to confirm.' }); + const enrollmentRow = await enrollmentLib.getEnrollment(pool, targetEnrollmentId); + const program = await programsLib.getProgram(pool, enrollmentRow.program_id); + + const notification = await notificationsLib.enqueue(pool, { + studentId: req.params.id, enrollmentId: targetEnrollmentId, notificationType: 'enrollment_confirmation', + metadata: { display_name: rows[0].display_name, program_name: program?.name }, + }); + await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_confirmation_sent', resourceType: 'enrollment', resourceId: targetEnrollmentId, result: 'success', sourceIp: req.ip }); + res.json(notificationsLib.toAdminView(notification)); + }); + + // §8/§15's "admin may need a safe SEND PASSWORD RESET operation" is the EXISTING + // POST /api/admin/students/:id/reset-password (admin-routes.js, Phase 5A) — Phase 5C extended + // it to also enqueue a real email (see that route) rather than duplicating a second endpoint + // here. It still returns `reset_token` in its response, unchanged from Phase 5A: for a + // student with no deliverable address at all (no email, no enrollment contact), manually + // relaying that token is the ONLY way they can ever get in, so removing it would leave that + // population with no recovery path. §21's "admin cannot view token" is enforced for + // resend-activation/send-enrollment-confirmation above, which have no legitimate reason to + // ever expose one — reset-password's token-in-response is a deliberate, documented, + // unchanged exception for exactly the case automatic delivery can't cover. See + // docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6. +};