Add student account self-service security features and discoverability fixes; v2.1.0

Adds forgot/reset password, signup email verification, sign-out-of-all-devices,
note-usage and certificate display in account settings, and password-changed
security emails. Fixes study sessions being lost on server restart by
persisting them to disk, and fixes sanitizeStudyUsers silently dropping the
new pending-token fields on load. Also surfaces the lesson discussion board
in the main site nav, trims the sign-in page for returning users, and renames
colossiansStudy.tsx/colossiansStudyData.ts to studyPages.tsx/legacyColossiansData.ts
now that the study system covers more than Colossians.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-24 09:21:54 -04:00
1 parent a6e8243705
commit 841e754e16
16 files changed
+727 -50

No files matched your search

+24 -5
View File
@@ -3,8 +3,8 @@ import rateLimit from 'express-rate-limit'
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
import { mkdir, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl } from '../helpers.js'
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS } from '../config.js'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl, parseCookies } from '../helpers.js'
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_NOTE_LENGTH, STUDY_SESSION_COOKIE } from '../config.js'
import { state } from '../state.js'
import {
queueStudyUsersWrite,
@@ -30,9 +30,11 @@ import {
normalizeStudySlug,
getStudyCatalog,
clearStudySessionCookie,
invalidateAllStudySessions,
} from '../study-helpers.js'
import {
sendStudyAccountDeletedEmail,
sendStudyPasswordChangedEmail,
syncContactToResend,
buildBrandedEmailHtml,
getCanonicalBaseUrl,
@@ -187,6 +189,11 @@ export function register(app) {
user.passwordHash = hashStudyPassword(newPassword)
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
invalidateAllStudySessions(user.id, parseCookies(req.headers.cookie)[STUDY_SESSION_COOKIE])
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
.catch(err => console.error('[study-account] password-changed email error:', err))
res.json({ ok: true })
})
@@ -213,6 +220,16 @@ export function register(app) {
}
})
const baseUrl = getCanonicalBaseUrl()
const certificates = state.studyCertificates
.filter(c => c.userId === user.id)
.map(c => ({
studySlug: c.studySlug,
studyTitle: c.studyTitle,
issuedAt: c.issuedAt,
url: buildAbsoluteUrl(baseUrl, `/study/certificate/${c.token}`),
}))
res.json({
profile: {
username: user.username,
@@ -222,9 +239,12 @@ export function register(app) {
reminderFrequency: user.reminderFrequency ?? 'immediate',
remindersPausedUntil: user.remindersPausedUntil ?? null,
avatarUrl: getStudyAvatarUrl(user),
emailVerified: user.emailVerified === true,
},
stats: {
noteCount: Object.keys(notes).length,
noteLimit: MAX_STUDY_NOTES_PER_USER,
noteCharLimit: MAX_STUDY_NOTE_LENGTH,
memberSince: user.createdAt,
lastLoginAt: user.lastLoginAt,
currentStreak: user.currentStreak ?? 0,
@@ -232,6 +252,7 @@ export function register(app) {
lastStudiedDate: user.lastStudiedDate ?? null,
},
studies,
certificates,
})
})
@@ -478,9 +499,7 @@ export function register(app) {
const deletedEmail = user.username
const deletedDisplayName = user.displayName || user.username
for (const [token, session] of state.studySessions) {
if (session.userId === user.id) state.studySessions.delete(token)
}
invalidateAllStudySessions(user.id)
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
queueStudyUsersWrite()