From 841e754e1689f0fc1dbf5d6dc2a1596c8bc8e12b Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 24 Sep 2026 09:21:54 -0400 Subject: [PATCH] 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 --- .gitignore | 1 + package.json | 2 +- server.js | 7 +- server/config.js | 3 + server/data.js | 43 +++ server/email.js | 100 ++++++ server/routes/admin-assets.js | 13 +- server/routes/study-account.js | 29 +- server/routes/study-auth.js | 161 ++++++++- server/state.js | 1 + server/study-helpers.js | 23 +- src/AdminPage.tsx | 26 ++ src/App.tsx | 8 +- src/content.ts | 23 +- ...nsStudyData.ts => legacyColossiansData.ts} | 0 src/{colossiansStudy.tsx => studyPages.tsx} | 337 ++++++++++++++++-- 16 files changed, 727 insertions(+), 50 deletions(-) rename src/{colossiansStudyData.ts => legacyColossiansData.ts} (100%) rename src/{colossiansStudy.tsx => studyPages.tsx} (92%) diff --git a/.gitignore b/.gitignore index 7215005..e9a4865 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ data/visitor-stats.json data/contact-submissions.json data/totp-secret.json data/uploads-meta.json +data/study-sessions.json # Local environment secrets — never commit .env diff --git a/package.json b/package.json index eebe059..74cb672 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.50", + "version": "2.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 25e34c7..975b7c5 100644 --- a/server.js +++ b/server.js @@ -27,6 +27,8 @@ import { loadCalendarEventsFromDisk, loadAuditLogFromDisk, loadWebhooksFromDisk, + loadStudySessionsFromDisk, + queueStudySessionsWrite, createBackupSnapshot, refreshContentCaches, queueHitStatsWrite, @@ -165,6 +167,7 @@ Promise.all([ loadCalendarEventsFromDisk(), loadAuditLogFromDisk(), loadWebhooksFromDisk(), + loadStudySessionsFromDisk(), refreshContentCaches(), ]) .catch(err => { @@ -182,9 +185,11 @@ Promise.all([ // Purge expired study sessions every hour setInterval(() => { const now = Date.now() + let pruned = false for (const [token, session] of state.studySessions) { - if (session.expiresAt <= now) state.studySessions.delete(token) + if (session.expiresAt <= now) { state.studySessions.delete(token); pruned = true } } + if (pruned) queueStudySessionsWrite() }, 60 * 60 * 1000) // Send study reminder emails for newly released lessons every hour diff --git a/server/config.js b/server/config.js index 3129b10..a24c135 100644 --- a/server/config.js +++ b/server/config.js @@ -73,6 +73,7 @@ export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json') export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json') export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json') export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json') +export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json') export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon // Seafile integration for Nuggets episode uploads @@ -124,6 +125,8 @@ export const MAX_STUDY_ENROLLMENTS_PER_USER = 100 export const MAX_STUDY_NOTES_PER_USER = 500 export const MAX_STUDY_NOTE_LENGTH = 12000 export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000 +export const EMAIL_VERIFY_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000 +export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000 export const STUDY_SESSION_COOKIE = 'vbn_study_session' export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000 diff --git a/server/data.js b/server/data.js index 06b1ccc..0bec49f 100644 --- a/server/data.js +++ b/server/data.js @@ -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, diff --git a/server/email.js b/server/email.js index cc53b6a..e48f3c0 100644 --- a/server/email.js +++ b/server/email.js @@ -485,6 +485,106 @@ export async function sendStudyWelcomeEmail(email, displayName) { } } +export async function sendStudyVerifyEmail(email, displayName, verifyUrl) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = state.cachedSiteContent ?? {} + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const subject = cfg.emailVerifySubject?.trim() || 'Verify your email address' + const bodyText = cfg.emailVerifyBody?.trim() || 'Please confirm this is your email address so we can keep your study account secure and deliver reminders you opt into.' + const ctaLabel = cfg.emailVerifyCtaLabel?.trim() || 'Verify Email Address' + const bodyHtml = ( + `

Hi ${escapeHtml(namePart)},

` + + `

${escapeHtml(bodyText)}

` + ) + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `Hi ${namePart},\n\n${bodyText}\n\n${verifyUrl}\n\nVerse by Verse with Nate`, + html: buildBrandedEmailHtml({ + title: 'Verify Your Email', + eyebrow: 'Study Account', + bodyHtml, + ctaLabel, + ctaUrl: verifyUrl, + footerHtml: `

If you didn't create this account, you can ignore this message.

`, + }), + }) + if (error) console.error('[study-verify-email] send error:', error) + } catch (err) { + console.error('[study-verify-email] send exception:', err) + } +} + +export async function sendStudyPasswordResetEmail(email, displayName, resetUrl) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = state.cachedSiteContent ?? {} + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const subject = cfg.passwordResetSubject?.trim() || 'Reset your password' + const bodyText = cfg.passwordResetBody?.trim() || 'We received a request to reset the password on your study account. Click below to choose a new password. This link expires in 1 hour.' + const ctaLabel = cfg.passwordResetCtaLabel?.trim() || 'Reset Password' + const bodyHtml = ( + `

Hi ${escapeHtml(namePart)},

` + + `

${escapeHtml(bodyText)}

` + ) + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `Hi ${namePart},\n\n${bodyText}\n\n${resetUrl}\n\nIf you didn't request this, you can safely ignore this email — your password won't change.\n\nVerse by Verse with Nate`, + html: buildBrandedEmailHtml({ + title: 'Reset Your Password', + eyebrow: 'Account Security', + bodyHtml, + ctaLabel, + ctaUrl: resetUrl, + footerHtml: `

If you didn't request this, you can safely ignore this email — your password won't change.

`, + }), + }) + if (error) console.error('[study-password-reset] send error:', error) + } catch (err) { + console.error('[study-password-reset] send exception:', err) + } +} + +export async function sendStudyPasswordChangedEmail(email, displayName) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = state.cachedSiteContent ?? {} + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const baseUrl = getCanonicalBaseUrl() + const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account') + const subject = cfg.passwordChangedSubject?.trim() || 'Your password was changed' + const bodyText = cfg.passwordChangedBody?.trim() || 'This confirms the password on your study account was just changed. All other active sessions have been signed out. If this wasn’t you, please contact us right away.' + const bodyHtml = ( + `

Hi ${escapeHtml(namePart)},

` + + `

${escapeHtml(bodyText)}

` + ) + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `Hi ${namePart},\n\n${bodyText}\n\nManage your account: ${accountUrl}\n\nVerse by Verse with Nate`, + html: buildBrandedEmailHtml({ + title: 'Password Changed', + eyebrow: 'Account Security', + bodyHtml, + ctaLabel: 'Manage Account', + ctaUrl: accountUrl, + footerHtml: `

Verse by Verse with Nate

`, + }), + }) + if (error) console.error('[study-password-changed] send error:', error) + } catch (err) { + console.error('[study-password-changed] send exception:', err) + } +} + export async function sendEmailOtp(email, code) { if (!process.env.RESEND_API_KEY) return try { diff --git a/server/routes/admin-assets.js b/server/routes/admin-assets.js index 222e51a..3950f69 100644 --- a/server/routes/admin-assets.js +++ b/server/routes/admin-assets.js @@ -19,8 +19,9 @@ import { normalizeStudySlug, isEnrollableStudySlug, makeUnsubUrl, + invalidateAllStudySessions, } from '../study-helpers.js' -import { sendStudyReengagementEmail } from '../email.js' +import { sendStudyReengagementEmail, sendStudyPasswordChangedEmail } from '../email.js' export function register(app) { app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => { @@ -237,9 +238,9 @@ export function register(app) { res.status(400).json({ message: 'Password must be 8–200 characters.' }); return } user.passwordHash = hashStudyPassword(newPassword) - for (const [token, session] of state.studySessions) { - if (session.userId === user.id) state.studySessions.delete(token) - } + invalidateAllStudySessions(user.id) + sendStudyPasswordChangedEmail(user.username, user.displayName || user.username) + .catch(err => console.error('[admin] password-changed email error:', err)) } if (typeof addEnrollment === 'string' && addEnrollment.trim()) { @@ -265,9 +266,7 @@ export function register(app) { const user = state.studyUsers.find(u => u.id === req.params.id) if (!user) { res.status(404).json({ message: 'User not found.' }); return } - 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() diff --git a/server/routes/study-account.js b/server/routes/study-account.js index a444f53..e90bcd1 100644 --- a/server/routes/study-account.js +++ b/server/routes/study-account.js @@ -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() diff --git a/server/routes/study-auth.js b/server/routes/study-auth.js index 5211b50..ee7516d 100644 --- a/server/routes/study-auth.js +++ b/server/routes/study-auth.js @@ -6,12 +6,13 @@ import { verifyTotpCode, generateRecoveryCodes, } from '../auth.js' -import { parseCookies } from '../helpers.js' -import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.js' +import { parseCookies, buildAbsoluteUrl } from '../helpers.js' +import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS, PASSWORD_RESET_TOKEN_TTL_MS, EMAIL_VERIFY_TOKEN_TTL_MS } from '../config.js' import { state } from '../state.js' import { queueStudyUsersWrite, queueContactSubmissionsWrite, + queueStudySessionsWrite, normalizeContactEmailStatus, normalizeMessageType, } from '../data.js' @@ -19,12 +20,15 @@ import { normalizeStudyUsername, isValidStudyUsername, hashStudyPassword, + hashPasswordResetToken, + hashEmailVerifyToken, findStudyUserByUsername, getStudyAvatarUrl, createStudySession, setStudySessionCookie, clearStudySessionCookie, requireStudyAuth, + invalidateAllStudySessions, createStudyTotpPendingToken, consumeStudyTotpPendingToken, generateEmailOtp, @@ -32,7 +36,15 @@ import { verifyEmailOtp, getStudyUserFromRequest, } from '../study-helpers.js' -import { sendEmailOtp, sendStudyWelcomeEmail, syncContactToResend } from '../email.js' +import { + sendEmailOtp, + sendStudyWelcomeEmail, + sendStudyVerifyEmail, + sendStudyPasswordResetEmail, + sendStudyPasswordChangedEmail, + syncContactToResend, + getCanonicalBaseUrl, +} from '../email.js' const studyAuthRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, @@ -54,6 +66,7 @@ export function register(app) { studyRemindersEnabled: user?.studyRemindersEnabled === true, avatarUrl: user ? getStudyAvatarUrl(user) : '', enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [], + emailVerified: user?.emailVerified === true, totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))), twoFaMethod: user?.twoFaMethod ?? null, totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0, @@ -87,6 +100,7 @@ export function register(app) { } const now = new Date().toISOString() + const rawVerifyToken = randomUUID() const user = { id: randomUUID(), username, @@ -95,6 +109,11 @@ export function register(app) { subscribeNewsletter: subscribe, studyRemindersEnabled: false, pendingEmailChange: null, + emailVerified: false, + pendingEmailVerification: { + tokenHash: hashEmailVerifyToken(rawVerifyToken), + expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS, + }, enrolledStudySlugs: [], createdAt: now, updatedAt: now, @@ -123,6 +142,8 @@ export function register(app) { } sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err)) + const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawVerifyToken)}`) + sendStudyVerifyEmail(username, displayName || username, verifyUrl).catch(err => console.error('[study-signup] verify email error:', err)) const sessionToken = createStudySession(user.id) setStudySessionCookie(res, sessionToken) @@ -356,11 +377,145 @@ export function register(app) { const token = cookies[STUDY_SESSION_COOKIE] if (token) { state.studySessions.delete(token) + queueStudySessionsWrite() } clearStudySessionCookie(res) res.json({ ok: true }) }) + // Signs out every other active session for the current user (this device stays signed in). + app.post('/api/study-auth/logout-all', requireStudyAuth, (req, res) => { + const cookies = parseCookies(req.headers.cookie) + const currentToken = cookies[STUDY_SESSION_COOKIE] + invalidateAllStudySessions(req.studyUser.id, currentToken) + res.json({ ok: true }) + }) + + // Request a password-reset email. Always responds ok to avoid leaking which emails have accounts. + app.post('/api/study-auth/forgot-password', studyAuthRateLimiter, async (req, res) => { + const username = normalizeStudyUsername(req.body?.username) + if (!isValidStudyUsername(username)) { + res.status(400).json({ message: 'Please enter a valid email address.' }) + return + } + + const user = findStudyUserByUsername(username) + if (user) { + const rawToken = randomUUID() + user.pendingPasswordReset = { + tokenHash: hashPasswordResetToken(rawToken), + expiresAt: Date.now() + PASSWORD_RESET_TOKEN_TTL_MS, + } + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + + const resetUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/reset-password?token=${encodeURIComponent(rawToken)}`) + sendStudyPasswordResetEmail(user.username, user.displayName || user.username, resetUrl) + .catch(err => console.error('[study-forgot-password] send error:', err)) + } + + res.json({ ok: true, message: 'If that email has an account, a reset link is on its way.' }) + }) + + app.post('/api/study-auth/reset-password', studyAuthRateLimiter, (req, res) => { + const token = typeof req.body?.token === 'string' ? req.body.token.trim() : '' + const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : '' + + if (!token) { + res.status(400).json({ message: 'Invalid or missing reset token.' }) + return + } + if (newPassword.length < 8 || newPassword.length > 200) { + res.status(400).json({ message: 'New password must be 8–200 characters.' }) + return + } + + const tokenHash = hashPasswordResetToken(token) + const user = state.studyUsers.find(u => { + const pending = u.pendingPasswordReset + if (!pending?.tokenHash) return false + const a = Buffer.from(tokenHash, 'utf8') + const b = Buffer.from(pending.tokenHash, 'utf8') + return a.length === b.length && timingSafeEqual(a, b) + }) + + if (!user) { + res.status(400).json({ message: 'This reset link is invalid. Please request a new one.' }) + return + } + if (user.pendingPasswordReset.expiresAt <= Date.now()) { + user.pendingPasswordReset = null + queueStudyUsersWrite() + res.status(400).json({ message: 'This reset link has expired. Please request a new one.' }) + return + } + + user.passwordHash = hashStudyPassword(newPassword) + user.pendingPasswordReset = null + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + invalidateAllStudySessions(user.id) + + sendStudyPasswordChangedEmail(user.username, user.displayName || user.username) + .catch(err => console.error('[study-reset-password] confirmation email error:', err)) + + res.json({ ok: true }) + }) + + // Verify the email address used at signup. Not auth-gated so it works from any device/browser. + app.post('/api/study-auth/verify-email', studyAuthRateLimiter, (req, res) => { + const token = typeof req.body?.token === 'string' ? req.body.token.trim() : '' + if (!token) { + res.status(400).json({ message: 'Invalid or missing verification token.' }) + return + } + + const tokenHash = hashEmailVerifyToken(token) + const user = state.studyUsers.find(u => { + const pending = u.pendingEmailVerification + if (!pending?.tokenHash) return false + const a = Buffer.from(tokenHash, 'utf8') + const b = Buffer.from(pending.tokenHash, 'utf8') + return a.length === b.length && timingSafeEqual(a, b) + }) + + if (!user) { + res.status(400).json({ message: 'This verification link is invalid or has already been used.' }) + return + } + if (user.pendingEmailVerification.expiresAt <= Date.now()) { + res.status(400).json({ message: 'This verification link has expired. Request a new one from My Account.' }) + return + } + + user.emailVerified = true + user.pendingEmailVerification = null + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + res.json({ ok: true }) + }) + + app.post('/api/study-auth/resend-verification', studyAuthRateLimiter, requireStudyAuth, (req, res) => { + const user = req.studyUser + if (user.emailVerified) { + res.json({ ok: true, alreadyVerified: true }) + return + } + const rawToken = randomUUID() + user.pendingEmailVerification = { + tokenHash: hashEmailVerifyToken(rawToken), + expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS, + } + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + + const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawToken)}`) + sendStudyVerifyEmail(user.username, user.displayName || user.username, verifyUrl) + .catch(err => console.error('[study-resend-verification] send error:', err)) + + res.json({ ok: true }) + }) + // One-click unsubscribe from re-engagement emails (no login required) app.get('/api/study-auth/unsubscribe-reminders', (req, res) => { const { uid, token } = req.query ?? {} diff --git a/server/state.js b/server/state.js index 04a6ead..dbd88ec 100644 --- a/server/state.js +++ b/server/state.js @@ -116,6 +116,7 @@ export const state = { studyProgressWriteQueues: new Map(), // userId -> Promise studySessions: new Map(), + studySessionsWritePromise: Promise.resolve(), studyTotpPendingTokens: new Map(), emailOtpStore: new Map(), diff --git a/server/study-helpers.js b/server/study-helpers.js index 1d6aac8..b68c9e5 100644 --- a/server/study-helpers.js +++ b/server/study-helpers.js @@ -22,7 +22,7 @@ import { DOWNLOAD_TOKEN_TTL_MS, } from './config.js' import { state } from './state.js' -import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js' +import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite } from './data.js' // ── Username / slug normalizers ──────────────────────────────────────────── @@ -64,6 +64,14 @@ export function hashEmailChangeToken(token) { return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex') } +export function hashPasswordResetToken(token) { + return createHash('sha256').update(`study-password-reset:${String(token)}`).digest('hex') +} + +export function hashEmailVerifyToken(token) { + return createHash('sha256').update(`study-email-verify:${String(token)}`).digest('hex') +} + // ── Catalog / enrollment helpers ─────────────────────────────────────────── export function getStudyCatalog() { @@ -144,9 +152,22 @@ export function getStudyAvatarUrl(subject) { export function createStudySession(userId) { const token = randomUUID() state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS }) + queueStudySessionsWrite() return token } +// Revokes every active session for a user (password change/reset, account deletion, "sign out everywhere"). +export function invalidateAllStudySessions(userId, exceptToken = null) { + let changed = false + for (const [token, session] of state.studySessions) { + if (session.userId === userId && token !== exceptToken) { + state.studySessions.delete(token) + changed = true + } + } + if (changed) queueStudySessionsWrite() +} + export function setStudySessionCookie(res, token) { res.append( 'Set-Cookie', diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index b8d7ddf..2e456c2 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -481,6 +481,24 @@ function EmailTemplatesPanel({ fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']} openKey="email-change" /> + + + + + + { function handleOutsideClick(e: MouseEvent) { @@ -444,6 +445,9 @@ function SiteHeader({ content }: { content: SiteContent }) {
`nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies + {communityStudy && ( + `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Discussion + )} `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index
@@ -3209,6 +3213,8 @@ export default function App() { } /> } />} /> } />} /> + } />} /> + } />} /> } />} /> } />} /> } />} /> diff --git a/src/content.ts b/src/content.ts index 4e9c91b..1e5a0a0 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,5 +1,5 @@ -import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData' -import type { ColossiansStudySection as ColossiansStudySectionData } from './colossiansStudyData' +import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData' +import type { ColossiansStudySection as ColossiansStudySectionData } from './legacyColossiansData' export type ColossiansStudySection = ColossiansStudySectionData export type StudySection = ColossiansStudySectionData @@ -296,6 +296,17 @@ export interface SiteContent { emailChangeSubject: string emailChangeBody: string emailChangeCtaLabel: string + // ── Verify Email Address (signup) ── + emailVerifySubject: string + emailVerifyBody: string + emailVerifyCtaLabel: string + // ── Password Reset ── + passwordResetSubject: string + passwordResetBody: string + passwordResetCtaLabel: string + // ── Password Changed Confirmation ── + passwordChangedSubject: string + passwordChangedBody: string // ── 2FA Email OTP ── twoFaOtpEmailSubject: string twoFaOtpEmailBody: string @@ -548,6 +559,14 @@ export const DEFAULTS: SiteContent = { emailChangeSubject: 'Confirm your new email address', emailChangeBody: 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.', emailChangeCtaLabel: 'Confirm Email Change', + emailVerifySubject: 'Verify your email address', + emailVerifyBody: 'Please confirm this is your email address so we can keep your study account secure and deliver reminders you opt into.', + emailVerifyCtaLabel: 'Verify Email Address', + passwordResetSubject: 'Reset your password', + passwordResetBody: 'We received a request to reset the password on your study account. Click below to choose a new password. This link expires in 1 hour.', + passwordResetCtaLabel: 'Reset Password', + passwordChangedSubject: 'Your password was changed', + passwordChangedBody: 'This confirms the password on your study account was just changed. All other active sessions have been signed out. If this wasn’t you, please contact us right away.', twoFaOtpEmailSubject: 'Your sign-in code — Verse by Verse with Nate', twoFaOtpEmailBody: 'Your two-factor sign-in code is below. Enter it to complete sign-in.', twoFaOtpEmailExpiry: 'This code expires in 10 minutes. If you did not request this, you can ignore this message.', diff --git a/src/colossiansStudyData.ts b/src/legacyColossiansData.ts similarity index 100% rename from src/colossiansStudyData.ts rename to src/legacyColossiansData.ts diff --git a/src/colossiansStudy.tsx b/src/studyPages.tsx similarity index 92% rename from src/colossiansStudy.tsx rename to src/studyPages.tsx index 83e3d0c..a086015 100644 --- a/src/colossiansStudy.tsx +++ b/src/studyPages.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' import type { SiteContent, StudyProgram, StudySection } from './content' -import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData' +import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData' import { usePageMeta } from './hooks/usePageMeta' import { Breadcrumbs } from './components/Breadcrumbs' import { StudyCertificate } from './components/StudyCertificate' @@ -23,6 +23,7 @@ type StudyAuthState = { totpEnabled?: boolean twoFaMethod?: 'app' | 'email' | null totpRecoveryCodesRemaining?: number + emailVerified?: boolean } type StudyAuthStatusResponse = { @@ -36,6 +37,7 @@ type StudyAuthStatusResponse = { totpEnabled?: boolean twoFaMethod?: 'app' | 'email' | null totpRecoveryCodesRemaining?: number + emailVerified?: boolean } type StudyAccountOverview = { @@ -47,9 +49,12 @@ type StudyAccountOverview = { reminderFrequency?: string remindersPausedUntil?: string | null avatarUrl?: string + emailVerified?: boolean } stats: { noteCount: number + noteLimit: number + noteCharLimit: number memberSince: string lastLoginAt: string | null currentStreak: number @@ -65,6 +70,12 @@ type StudyAccountOverview = { completedLessons: number noteCount: number }> + certificates: Array<{ + studySlug: string + studyTitle: string + issuedAt: string + url: string + }> } type StudyNotesMap = Record @@ -1038,7 +1049,7 @@ const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2, export function StudySignupPage() { const navigate = useNavigate() - const [mode, setMode] = useState<'signup' | 'login'>('login') + const [mode, setMode] = useState<'signup' | 'login' | 'forgot'>('login') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [subscribeNewsletter, setSubscribeNewsletter] = useState(true) @@ -1052,6 +1063,8 @@ export function StudySignupPage() { const [totpCode, setTotpCode] = useState('') const [totpMethod, setTotpMethod] = useState<'app' | 'email' | null>(null) const [resendStatus, setResendStatus] = useState<'idle' | 'sending' | 'sent'>('idle') + // Forgot-password state + const [forgotSent, setForgotSent] = useState(false) useEffect(() => { readJson('/api/study-auth/status') @@ -1116,11 +1129,32 @@ export function StudySignupPage() { } }, [totpPendingToken, totpCode, email]) + const submitForgot = useCallback(async () => { + if (!email.trim() || !EMAIL_REGEX.test(email.trim())) { + setMessage('Please enter a valid email address.') + return + } + setBusy(true) + setMessage('') + try { + await readJson<{ ok: boolean }>('/api/study-auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: email }), + }) + setForgotSent(true) + } catch (err) { + setMessage(err instanceof Error ? err.message : 'Something went wrong. Please try again.') + } finally { + setBusy(false) + } + }, [email]) + return ( -
+

Free Student Account

-

{mode === 'signup' ? 'Create Your Account' : 'Welcome Back'}

+

{mode === 'signup' ? 'Create Your Account' : mode === 'forgot' ? 'Reset Your Password' : 'Welcome Back'}

{done ? ( <> @@ -1131,6 +1165,41 @@ export function StudySignupPage() { My Account
+ ) : mode === 'forgot' ? ( +
+ {forgotSent ? ( + <> +

If that email has an account, a reset link is on its way.

+

Check your inbox and follow the link to choose a new password. The link expires in 1 hour.

+ + ) : ( + <> +

+ Enter the email address on your account and we'll send you a link to reset your password. +

+ + setEmail(e.target.value)} + placeholder="your@email.com" + autoComplete="email" + autoFocus + onKeyDown={e => e.key === 'Enter' && submitForgot()} + /> + {message &&

{message}

} +
+ +
+ + )} +
+ +
+
) : totpPendingToken ? (
{totpMethod === 'email' ? ( @@ -1220,6 +1289,11 @@ export function StudySignupPage() { onKeyDown={e => e.key === 'Enter' && submit()} /> {fieldErrors.password &&

{fieldErrors.password}

} + {mode === 'login' && ( + + )} {mode === 'signup' && (
-
-
- -

Notes on Every Lesson

-

Save your own notes per lesson — up to 500 notes across all tracks.

-
-
- -

Space to Go Deep

-

Each note holds ~2,000 words so you can write as much as you need.

-
-
- -

Private to You

-

Your notes are tied to your account. Nobody else can read them.

-
-
- -

Free Forever

-

Just an email and password. No charge, no ads. Stay signed in 30 days.

-
-
+ {mode === 'signup' && ( +
+
+ +

Notes on Every Lesson

+

Save your own notes per lesson — up to 500 notes across all tracks.

+
+
+ +

Space to Go Deep

+

Each note holds ~2,000 words so you can write as much as you need.

+
+
+ +

Private to You

+

Your notes are tied to your account. Nobody else can read them.

+
+
+ +

Free Forever

+

Just an email and password. No charge, no ads. Stay signed in 30 days.

+
+
+ )} )} @@ -1284,6 +1360,134 @@ export function StudySignupPage() { ) } +export function StudyResetPasswordPage() { + const navigate = useNavigate() + const location = useLocation() + const token = useMemo(() => new URLSearchParams(location.search).get('token') ?? '', [location.search]) + + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState('') + const [done, setDone] = useState(false) + + const submit = useCallback(async () => { + if (!token) { setMessage('This reset link is missing its token.'); return } + if (newPassword.length < 8) { setMessage('New password must be at least 8 characters.'); return } + if (newPassword !== confirmPassword) { setMessage('Passwords do not match.'); return } + setBusy(true) + setMessage('') + try { + await readJson<{ ok: boolean }>('/api/study-auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, newPassword }), + }) + setDone(true) + } catch (err) { + setMessage(err instanceof Error ? err.message : 'Unable to reset password. Please request a new link.') + } finally { + setBusy(false) + } + }, [token, newPassword, confirmPassword]) + + return ( +
+
+

Free Student Account

+

Reset Your Password

+ + {!token ? ( +

This link is missing its token. Please use the link from your email, or request a new one.

+ ) : done ? ( + <> +

Your password has been reset.

+

You've been signed out of all devices for security. Sign in below with your new password.

+ + + ) : ( +
+ + setNewPassword(e.target.value)} + placeholder="At least 8 characters" + autoComplete="new-password" + autoFocus + /> + + setConfirmPassword(e.target.value)} + placeholder="Re-enter new password" + autoComplete="new-password" + onKeyDown={e => e.key === 'Enter' && submit()} + /> + {message &&

{message}

} +
+ +
+
+ )} + +
+ Back to Sign In +
+
+
+ ) +} + +export function StudyVerifyEmailPage() { + const location = useLocation() + const token = useMemo(() => new URLSearchParams(location.search).get('token') ?? '', [location.search]) + const [status, setStatus] = useState<'checking' | 'ok' | 'error'>(token ? 'checking' : 'error') + const [message, setMessage] = useState(token ? '' : 'This link is missing its token.') + + useEffect(() => { + if (!token) return + readJson<{ ok: boolean }>('/api/study-auth/verify-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + .then(() => setStatus('ok')) + .catch(err => { + setStatus('error') + setMessage(err instanceof Error ? err.message : 'Unable to verify this link.') + }) + }, [token]) + + return ( +
+
+

Free Student Account

+

Verify Your Email

+ + {status === 'checking' &&

Verifying…

} + {status === 'ok' && ( + <> +

Your email address is verified.

+

Thanks for confirming — you're all set.

+ + )} + {status === 'error' &&

{message}

} + +
+ My Account + Back to Studies +
+
+
+ ) +} + export function ColossiansStudyIndexPage({ content }: Props) { const { studySlug } = useParams<{ studySlug?: string }>() const studies = getStudies(content) @@ -2589,6 +2793,11 @@ export function StudyAccountPage() { const [pwSuccess, setPwSuccess] = useState(false) const [signOutBusy, setSignOutBusy] = useState(false) + const [signOutAllBusy, setSignOutAllBusy] = useState(false) + const [signOutAllMessage, setSignOutAllMessage] = useState('') + + const [resendVerifyBusy, setResendVerifyBusy] = useState(false) + const [resendVerifyMessage, setResendVerifyMessage] = useState('') const [deleteStep, setDeleteStep] = useState<'idle' | 'warn1' | 'warn2'>('idle') const [deleteConfirmInput, setDeleteConfirmInput] = useState('') @@ -2611,6 +2820,7 @@ export function StudyAccountPage() { totpEnabled: data.totpEnabled === true, twoFaMethod: data.twoFaMethod ?? null, totpRecoveryCodesRemaining: data.totpRecoveryCodesRemaining ?? 0, + emailVerified: data.emailVerified === true, }) }) .catch(() => setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true })) @@ -2847,6 +3057,32 @@ export function StudyAccountPage() { } } + async function handleSignOutAllDevices() { + setSignOutAllBusy(true) + setSignOutAllMessage('') + try { + await readJson<{ ok: boolean }>('/api/study-auth/logout-all', { method: 'POST' }) + setSignOutAllMessage('Every other device has been signed out. This device stays signed in.') + } catch (err) { + setSignOutAllMessage(err instanceof Error ? err.message : 'Unable to sign out other devices.') + } finally { + setSignOutAllBusy(false) + } + } + + async function handleResendVerification() { + setResendVerifyBusy(true) + setResendVerifyMessage('') + try { + const data = await readJson<{ ok: boolean; alreadyVerified?: boolean }>('/api/study-auth/resend-verification', { method: 'POST' }) + setResendVerifyMessage(data.alreadyVerified ? 'Your email is already verified.' : 'Verification email sent. Check your inbox.') + } catch (err) { + setResendVerifyMessage(err instanceof Error ? err.message : 'Unable to send verification email.') + } finally { + setResendVerifyBusy(false) + } + } + async function handleDeleteAccount() { setDeleteBusy(true) setDeleteMessage('') @@ -2918,6 +3154,18 @@ export function StudyAccountPage() { + {auth.emailVerified === false && ( +
+ Verify your email. We sent a link to {auth.username} — click it to confirm this address. +
+ {resendVerifyMessage && {resendVerifyMessage}} + +
+
+ )} + {overview?.stats && ( <>
@@ -2957,6 +3205,31 @@ export function StudyAccountPage() { })}
)} + {overview.stats.noteLimit > 0 && ( +
+
+ Note Usage + {overview.stats.noteCount}/{overview.stats.noteLimit} notes · up to {overview.stats.noteCharLimit.toLocaleString()} characters each +
+
+
+
+
+ )} + {overview.certificates.length > 0 && ( +
+

Certificates

+ {overview.certificates.map(cert => ( +
+ 🏅 {cert.studyTitle} — {new Date(cert.issuedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +
+ View + +
+
+ ))} +
+ )} {divider} )} @@ -3451,9 +3724,15 @@ export function StudyAccountPage() {

Sign Out

- +
+ + +
+ {signOutAllMessage &&

{signOutAllMessage}

}
{divider}