feat(activation): wire notifications into enrollment/auth, real password-reset delivery
- integration-routes.js: accepts student.contact_email, calls notifyForEnrollment on a genuinely new enrollment only (never on an idempotent replay — inherits Phase 5B's proven enrollment-level idempotency, no separate notification dedup needed). - admin-routes.js: create-student/convert/reset-password now enqueue the corresponding notification using the SAME token already minted synchronously (never mints twice, which would silently invalidate the token just handed back in the response). Fixed a real bug: request-password-reset would mint a reset token for a never-activated student, which makes no sense and would confuse them — now falls through to the generic anti-enumeration response. - admin-programs-routes.js: manual enrollment triggers the same notification decision as the website path. - student-auth-routes.js: /activate records a metric; /change-password and /reset-password both queue a password_changed security notice; /request-password-reset now actually delivers email (production still never returns the token — unchanged anti-enumeration/isProduction gating, just now with a real send behind it). - rbac.js: requireStudentRelationshipAccess/checkStudentRelationshipAccess — the notification visibility rule (cohort-shared or an enrollment they created), reusable beyond just enrollments. - server.js: wires notification-routes.js and starts the notification worker in main(). - docker-compose.dev.yml: PUBLIC_APP_ORIGIN, EMAIL_FROM_NAME, and a parametrized COOKIE_SECURE (was hard-commented-out; needed so e2e-phase5c.js can actually prove production gating).
This commit is contained in:
@@ -153,6 +153,15 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
if (email || login_identifier) {
|
||||
const issued = await issueCredentialToken(pool, studentId, 'activation');
|
||||
activationToken = issued.token;
|
||||
// Phase 5C §5/§6 — reuses the SAME token just minted above (never mints a second one,
|
||||
// which would silently invalidate the one this response is about to hand the admin for
|
||||
// manual relay). A student with only login_identifier and no email has no deliverable
|
||||
// address here — enqueue() records that as an already-failed row (never silently
|
||||
// dropped), and the admin's manual-relay token in the response still works either way.
|
||||
await require('./notifications').enqueue(pool, {
|
||||
studentId, notificationType: 'activation', plaintextToken: activationToken,
|
||||
metadata: { display_name: display_name.trim() },
|
||||
});
|
||||
}
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
|
||||
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
|
||||
@@ -179,20 +188,34 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
throw e;
|
||||
}
|
||||
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
|
||||
// Phase 5C — same token, same "never mint twice" reasoning as create-student above.
|
||||
const { rows: nameRows } = await pool.query(`SELECT display_name FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
await require('./notifications').enqueue(pool, {
|
||||
studentId: req.params.id, notificationType: 'activation', plaintextToken: token,
|
||||
metadata: { display_name: nameRows[0]?.display_name },
|
||||
});
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ student_id: req.params.id, activation_token: token });
|
||||
});
|
||||
|
||||
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
|
||||
// token and revokes every existing session, same "instructor never needs to know the
|
||||
// student's password" principle as activation.
|
||||
// student's password" principle as activation. Phase 5C §8/§15: also queues a real
|
||||
// password-reset email whenever the student has a deliverable address — the response still
|
||||
// returns reset_token unchanged (a deliberate, documented exception to "admin cannot view
|
||||
// token" for the population with no deliverable address at all — see notification-routes.js's
|
||||
// header comment).
|
||||
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
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_converted', message: 'Convert this student to a real account first.' });
|
||||
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
|
||||
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
|
||||
await require('./notifications').enqueue(pool, {
|
||||
studentId: req.params.id, notificationType: 'password_reset', plaintextToken: token,
|
||||
metadata: { display_name: rows[0].display_name },
|
||||
});
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ student_id: req.params.id, reset_token: token });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user