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

+43
View File
@@ -34,6 +34,7 @@ import {
CALENDAR_EVENTS_FILE,
AUDIT_LOG_FILE,
WEBHOOKS_FILE,
STUDY_SESSIONS_FILE,
EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES,
@@ -496,6 +497,33 @@ export function loadWebhooksFromDisk() {
.catch(() => {})
}
// ── Study sessions ──────────────────────────────────────────────────────
export function queueStudySessionsWrite() {
state.studySessionsWritePromise = state.studySessionsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
const sessions = Array.from(state.studySessions.entries()).map(([token, session]) => ({ token, ...session }))
await writeFile(STUDY_SESSIONS_FILE, JSON.stringify({ sessions }, null, 2), 'utf8')
})
.catch(err => { console.error('[study-sessions] write failed:', err) })
}
export function loadStudySessionsFromDisk() {
return readFile(STUDY_SESSIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed?.sessions)) return
const now = Date.now()
for (const entry of parsed.sessions) {
if (!entry?.token || !entry?.userId || typeof entry.expiresAt !== 'number') continue
if (entry.expiresAt <= now) continue
state.studySessions.set(entry.token, { userId: entry.userId, expiresAt: entry.expiresAt })
}
})
.catch(() => {})
}
export async function fireWebhooks(eventType, payload) {
const targets = state.webhooks.filter(w =>
Array.isArray(w.events) && (w.events.includes(eventType) || w.events.includes('*'))
@@ -1435,6 +1463,18 @@ export function sanitizeStudyUsers(value) {
requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null,
}
: null
const pendingEmailVerification = item?.pendingEmailVerification && typeof item.pendingEmailVerification === 'object' && !Array.isArray(item.pendingEmailVerification)
? {
tokenHash: typeof item.pendingEmailVerification.tokenHash === 'string' ? item.pendingEmailVerification.tokenHash.trim() : '',
expiresAt: typeof item.pendingEmailVerification.expiresAt === 'number' ? item.pendingEmailVerification.expiresAt : 0,
}
: null
const pendingPasswordReset = item?.pendingPasswordReset && typeof item.pendingPasswordReset === 'object' && !Array.isArray(item.pendingPasswordReset)
? {
tokenHash: typeof item.pendingPasswordReset.tokenHash === 'string' ? item.pendingPasswordReset.tokenHash.trim() : '',
expiresAt: typeof item.pendingPasswordReset.expiresAt === 'number' ? item.pendingPasswordReset.expiresAt : 0,
}
: null
seen.add(username)
out.push({
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
@@ -1450,6 +1490,9 @@ export function sanitizeStudyUsers(value) {
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {},
pendingEmailChange,
emailVerified: item?.emailVerified === true,
pendingEmailVerification,
pendingPasswordReset,
twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null,
totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null,
totpVerified: item?.totpVerified === true,