Notes on Every Lesson
-Save your own notes per lesson — up to 500 notes across all tracks.
-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" /> +Free Student Account
-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}
} +{fieldErrors.password}
} + {mode === 'login' && ( + + )} {mode === 'signup' && (Save your own notes per lesson — up to 500 notes across all tracks.
-Each note holds ~2,000 words so you can write as much as you need.
-Your notes are tied to your account. Nobody else can read them.
-Just an email and password. No charge, no ads. Stay signed in 30 days.
-Save your own notes per lesson — up to 500 notes across all tracks.
+Each note holds ~2,000 words so you can write as much as you need.
+Your notes are tied to your account. Nobody else can read them.
+Just an email and password. No charge, no ads. Stay signed in 30 days.
+Free Student Account
+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.
+ + > + ) : ( +{message}
} +Free Student Account
+Verifying…
} + {status === 'ok' && ( + <> +Your email address is verified.
+Thanks for confirming — you're all set.
+ > + )} + {status === 'error' &&{message}
} + +Certificates
+ {overview.certificates.map(cert => ( +{signOutAllMessage}
}