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

+100
View File
@@ -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 = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
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: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't create this account, you can ignore this message.</p>`,
}),
})
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 = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
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: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't request this, you can safely ignore this email — your password won't change.</p>`,
}),
})
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 = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
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: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
}),
})
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 {