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:
@@ -10,6 +10,7 @@ import LearningView from './LearningView'
|
|||||||
import AssessmentsView from './AssessmentsView'
|
import AssessmentsView from './AssessmentsView'
|
||||||
import ClassroomView from './ClassroomView'
|
import ClassroomView from './ClassroomView'
|
||||||
import ProgramsView from './ProgramsView'
|
import ProgramsView from './ProgramsView'
|
||||||
|
import NotificationsView from './NotificationsView'
|
||||||
import styles from './Admin.module.css'
|
import styles from './Admin.module.css'
|
||||||
|
|
||||||
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||||
@@ -26,6 +27,7 @@ const NAV = [
|
|||||||
{ key: 'classroom', label: 'Classroom' },
|
{ key: 'classroom', label: 'Classroom' },
|
||||||
{ key: 'learning', label: 'Learning' },
|
{ key: 'learning', label: 'Learning' },
|
||||||
{ key: 'assessments', label: 'Assessments' },
|
{ key: 'assessments', label: 'Assessments' },
|
||||||
|
{ key: 'notifications', label: 'Notifications', roles: ['owner', 'admin'] },
|
||||||
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
|
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
|
||||||
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
|
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
|
||||||
]
|
]
|
||||||
@@ -67,6 +69,7 @@ export default function AdminPortal() {
|
|||||||
{view === 'classroom' && <ClassroomView />}
|
{view === 'classroom' && <ClassroomView />}
|
||||||
{view === 'learning' && <LearningView />}
|
{view === 'learning' && <LearningView />}
|
||||||
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
|
{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 === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
|
||||||
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
|
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
|
||||||
{view === 'account' && <AccountView instructor={instructor} />}
|
{view === 'account' && <AccountView instructor={instructor} />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -168,6 +168,7 @@ function StudentDetail({ studentId, canManage, onBack }) {
|
|||||||
const [events, setEvents] = useState(null)
|
const [events, setEvents] = useState(null)
|
||||||
const [sessions, setSessions] = useState(null)
|
const [sessions, setSessions] = useState(null)
|
||||||
const [enrollments, setEnrollments] = useState(null)
|
const [enrollments, setEnrollments] = useState(null)
|
||||||
|
const [notifications, setNotifications] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [notice, setNotice] = useState(null)
|
const [notice, setNotice] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
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
|
// 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.
|
// instructor only sees enrollments they created or that share a cohort with them.
|
||||||
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
|
adminApi.studentEnrollments(studentId).then(setEnrollments).catch(() => {})
|
||||||
|
// Phase 5C §16 — same scoping rule (rbac.js's requireStudentRelationshipAccess).
|
||||||
|
adminApi.studentNotifications(studentId).then(setNotifications).catch(() => {})
|
||||||
}, [studentId])
|
}, [studentId])
|
||||||
useEffect(reload, [reload])
|
useEffect(reload, [reload])
|
||||||
|
|
||||||
@@ -209,21 +212,47 @@ function StudentDetail({ studentId, canManage, onBack }) {
|
|||||||
<h3>Credentials</h3>
|
<h3>Credentials</h3>
|
||||||
{student?.has_credentials ? (
|
{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}>
|
<div className={styles.formRow}>
|
||||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
|
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
|
||||||
const { reset_token } = await adminApi.resetStudentPassword(studentId)
|
const { reset_token } = await adminApi.resetStudentPassword(studentId)
|
||||||
setNotice(`Password reset. Link (shown once): ${window.location.origin}/?reset=${reset_token}`)
|
setNotice(`Password reset. Link (shown once, for manual relay if email can't reach them): ${window.location.origin}/?reset=${reset_token}`)
|
||||||
})}>Reset password</button>
|
})}>Send password reset</button>
|
||||||
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
|
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
|
||||||
</div>
|
</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() }} />
|
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}>
|
<div className={styles.card}>
|
||||||
<h3>Environment</h3>
|
<h3>Environment</h3>
|
||||||
{!env ? <p className={styles.muted}>No Lab provisioned.</p> : (
|
{!env ? <p className={styles.muted}>No Lab provisioned.</p> : (
|
||||||
|
|||||||
@@ -128,4 +128,10 @@ export const adminApi = {
|
|||||||
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
|
activateEnrollment: (id) => post(`/api/admin/enrollments/${id}/activate`),
|
||||||
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
|
cancelEnrollment: (id) => post(`/api/admin/enrollments/${id}/cancel`),
|
||||||
studentEnrollments: (studentId) => get(`/api/admin/students/${studentId}/enrollments`),
|
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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user