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:
1 parent
a6e8243705
commit
841e754e16
16 files changed
+702
-25
No files matched your search
@@ -23,6 +23,7 @@ data/visitor-stats.json
|
|||||||
data/contact-submissions.json
|
data/contact-submissions.json
|
||||||
data/totp-secret.json
|
data/totp-secret.json
|
||||||
data/uploads-meta.json
|
data/uploads-meta.json
|
||||||
|
data/study-sessions.json
|
||||||
|
|
||||||
# Local environment secrets — never commit
|
# Local environment secrets — never commit
|
||||||
.env
|
.env
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.50",
|
"version": "2.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
loadCalendarEventsFromDisk,
|
loadCalendarEventsFromDisk,
|
||||||
loadAuditLogFromDisk,
|
loadAuditLogFromDisk,
|
||||||
loadWebhooksFromDisk,
|
loadWebhooksFromDisk,
|
||||||
|
loadStudySessionsFromDisk,
|
||||||
|
queueStudySessionsWrite,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
refreshContentCaches,
|
refreshContentCaches,
|
||||||
queueHitStatsWrite,
|
queueHitStatsWrite,
|
||||||
@@ -165,6 +167,7 @@ Promise.all([
|
|||||||
loadCalendarEventsFromDisk(),
|
loadCalendarEventsFromDisk(),
|
||||||
loadAuditLogFromDisk(),
|
loadAuditLogFromDisk(),
|
||||||
loadWebhooksFromDisk(),
|
loadWebhooksFromDisk(),
|
||||||
|
loadStudySessionsFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -182,9 +185,11 @@ Promise.all([
|
|||||||
// Purge expired study sessions every hour
|
// Purge expired study sessions every hour
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
let pruned = false
|
||||||
for (const [token, session] of state.studySessions) {
|
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)
|
}, 60 * 60 * 1000)
|
||||||
|
|
||||||
// Send study reminder emails for newly released lessons every hour
|
// Send study reminder emails for newly released lessons every hour
|
||||||
|
|||||||
@@ -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 CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
|
||||||
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.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 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
|
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||||
|
|
||||||
// Seafile integration for Nuggets episode uploads
|
// 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_NOTES_PER_USER = 500
|
||||||
export const MAX_STUDY_NOTE_LENGTH = 12000
|
export const MAX_STUDY_NOTE_LENGTH = 12000
|
||||||
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
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_COOKIE = 'vbn_study_session'
|
||||||
export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
CALENDAR_EVENTS_FILE,
|
CALENDAR_EVENTS_FILE,
|
||||||
AUDIT_LOG_FILE,
|
AUDIT_LOG_FILE,
|
||||||
WEBHOOKS_FILE,
|
WEBHOOKS_FILE,
|
||||||
|
STUDY_SESSIONS_FILE,
|
||||||
EMPTY_HIT_STATS,
|
EMPTY_HIT_STATS,
|
||||||
EMPTY_VISITOR_STATS,
|
EMPTY_VISITOR_STATS,
|
||||||
DEFAULT_REPLY_TEMPLATES,
|
DEFAULT_REPLY_TEMPLATES,
|
||||||
@@ -496,6 +497,33 @@ export function loadWebhooksFromDisk() {
|
|||||||
.catch(() => {})
|
.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) {
|
export async function fireWebhooks(eventType, payload) {
|
||||||
const targets = state.webhooks.filter(w =>
|
const targets = state.webhooks.filter(w =>
|
||||||
Array.isArray(w.events) && (w.events.includes(eventType) || w.events.includes('*'))
|
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,
|
requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null,
|
||||||
}
|
}
|
||||||
: 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)
|
seen.add(username)
|
||||||
out.push({
|
out.push({
|
||||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
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,
|
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
||||||
reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {},
|
reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {},
|
||||||
pendingEmailChange,
|
pendingEmailChange,
|
||||||
|
emailVerified: item?.emailVerified === true,
|
||||||
|
pendingEmailVerification,
|
||||||
|
pendingPasswordReset,
|
||||||
twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null,
|
twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null,
|
||||||
totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null,
|
totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null,
|
||||||
totpVerified: item?.totpVerified === true,
|
totpVerified: item?.totpVerified === true,
|
||||||
|
|||||||
+100
@@ -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) {
|
export async function sendEmailOtp(email, code) {
|
||||||
if (!process.env.RESEND_API_KEY) return
|
if (!process.env.RESEND_API_KEY) return
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import {
|
|||||||
normalizeStudySlug,
|
normalizeStudySlug,
|
||||||
isEnrollableStudySlug,
|
isEnrollableStudySlug,
|
||||||
makeUnsubUrl,
|
makeUnsubUrl,
|
||||||
|
invalidateAllStudySessions,
|
||||||
} from '../study-helpers.js'
|
} from '../study-helpers.js'
|
||||||
import { sendStudyReengagementEmail } from '../email.js'
|
import { sendStudyReengagementEmail, sendStudyPasswordChangedEmail } from '../email.js'
|
||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
|
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
|
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
|
||||||
}
|
}
|
||||||
user.passwordHash = hashStudyPassword(newPassword)
|
user.passwordHash = hashStudyPassword(newPassword)
|
||||||
for (const [token, session] of state.studySessions) {
|
invalidateAllStudySessions(user.id)
|
||||||
if (session.userId === user.id) state.studySessions.delete(token)
|
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
|
||||||
}
|
.catch(err => console.error('[admin] password-changed email error:', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof addEnrollment === 'string' && addEnrollment.trim()) {
|
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)
|
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||||
|
|
||||||
for (const [token, session] of state.studySessions) {
|
invalidateAllStudySessions(user.id)
|
||||||
if (session.userId === user.id) state.studySessions.delete(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
||||||
queueStudyUsersWrite()
|
queueStudyUsersWrite()
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import rateLimit from 'express-rate-limit'
|
|||||||
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
|
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
|
||||||
import { mkdir, unlink, writeFile } from 'node:fs/promises'
|
import { mkdir, unlink, writeFile } from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl } from '../helpers.js'
|
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl, parseCookies } from '../helpers.js'
|
||||||
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS } from '../config.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 { state } from '../state.js'
|
||||||
import {
|
import {
|
||||||
queueStudyUsersWrite,
|
queueStudyUsersWrite,
|
||||||
@@ -30,9 +30,11 @@ import {
|
|||||||
normalizeStudySlug,
|
normalizeStudySlug,
|
||||||
getStudyCatalog,
|
getStudyCatalog,
|
||||||
clearStudySessionCookie,
|
clearStudySessionCookie,
|
||||||
|
invalidateAllStudySessions,
|
||||||
} from '../study-helpers.js'
|
} from '../study-helpers.js'
|
||||||
import {
|
import {
|
||||||
sendStudyAccountDeletedEmail,
|
sendStudyAccountDeletedEmail,
|
||||||
|
sendStudyPasswordChangedEmail,
|
||||||
syncContactToResend,
|
syncContactToResend,
|
||||||
buildBrandedEmailHtml,
|
buildBrandedEmailHtml,
|
||||||
getCanonicalBaseUrl,
|
getCanonicalBaseUrl,
|
||||||
@@ -187,6 +189,11 @@ export function register(app) {
|
|||||||
user.passwordHash = hashStudyPassword(newPassword)
|
user.passwordHash = hashStudyPassword(newPassword)
|
||||||
user.updatedAt = new Date().toISOString()
|
user.updatedAt = new Date().toISOString()
|
||||||
queueStudyUsersWrite()
|
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 })
|
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({
|
res.json({
|
||||||
profile: {
|
profile: {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -222,9 +239,12 @@ export function register(app) {
|
|||||||
reminderFrequency: user.reminderFrequency ?? 'immediate',
|
reminderFrequency: user.reminderFrequency ?? 'immediate',
|
||||||
remindersPausedUntil: user.remindersPausedUntil ?? null,
|
remindersPausedUntil: user.remindersPausedUntil ?? null,
|
||||||
avatarUrl: getStudyAvatarUrl(user),
|
avatarUrl: getStudyAvatarUrl(user),
|
||||||
|
emailVerified: user.emailVerified === true,
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
noteCount: Object.keys(notes).length,
|
noteCount: Object.keys(notes).length,
|
||||||
|
noteLimit: MAX_STUDY_NOTES_PER_USER,
|
||||||
|
noteCharLimit: MAX_STUDY_NOTE_LENGTH,
|
||||||
memberSince: user.createdAt,
|
memberSince: user.createdAt,
|
||||||
lastLoginAt: user.lastLoginAt,
|
lastLoginAt: user.lastLoginAt,
|
||||||
currentStreak: user.currentStreak ?? 0,
|
currentStreak: user.currentStreak ?? 0,
|
||||||
@@ -232,6 +252,7 @@ export function register(app) {
|
|||||||
lastStudiedDate: user.lastStudiedDate ?? null,
|
lastStudiedDate: user.lastStudiedDate ?? null,
|
||||||
},
|
},
|
||||||
studies,
|
studies,
|
||||||
|
certificates,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -478,9 +499,7 @@ export function register(app) {
|
|||||||
const deletedEmail = user.username
|
const deletedEmail = user.username
|
||||||
const deletedDisplayName = user.displayName || user.username
|
const deletedDisplayName = user.displayName || user.username
|
||||||
|
|
||||||
for (const [token, session] of state.studySessions) {
|
invalidateAllStudySessions(user.id)
|
||||||
if (session.userId === user.id) state.studySessions.delete(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
||||||
queueStudyUsersWrite()
|
queueStudyUsersWrite()
|
||||||
|
|||||||
+158
-3
@@ -6,12 +6,13 @@ import {
|
|||||||
verifyTotpCode,
|
verifyTotpCode,
|
||||||
generateRecoveryCodes,
|
generateRecoveryCodes,
|
||||||
} from '../auth.js'
|
} from '../auth.js'
|
||||||
import { parseCookies } from '../helpers.js'
|
import { parseCookies, buildAbsoluteUrl } from '../helpers.js'
|
||||||
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.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 { state } from '../state.js'
|
||||||
import {
|
import {
|
||||||
queueStudyUsersWrite,
|
queueStudyUsersWrite,
|
||||||
queueContactSubmissionsWrite,
|
queueContactSubmissionsWrite,
|
||||||
|
queueStudySessionsWrite,
|
||||||
normalizeContactEmailStatus,
|
normalizeContactEmailStatus,
|
||||||
normalizeMessageType,
|
normalizeMessageType,
|
||||||
} from '../data.js'
|
} from '../data.js'
|
||||||
@@ -19,12 +20,15 @@ import {
|
|||||||
normalizeStudyUsername,
|
normalizeStudyUsername,
|
||||||
isValidStudyUsername,
|
isValidStudyUsername,
|
||||||
hashStudyPassword,
|
hashStudyPassword,
|
||||||
|
hashPasswordResetToken,
|
||||||
|
hashEmailVerifyToken,
|
||||||
findStudyUserByUsername,
|
findStudyUserByUsername,
|
||||||
getStudyAvatarUrl,
|
getStudyAvatarUrl,
|
||||||
createStudySession,
|
createStudySession,
|
||||||
setStudySessionCookie,
|
setStudySessionCookie,
|
||||||
clearStudySessionCookie,
|
clearStudySessionCookie,
|
||||||
requireStudyAuth,
|
requireStudyAuth,
|
||||||
|
invalidateAllStudySessions,
|
||||||
createStudyTotpPendingToken,
|
createStudyTotpPendingToken,
|
||||||
consumeStudyTotpPendingToken,
|
consumeStudyTotpPendingToken,
|
||||||
generateEmailOtp,
|
generateEmailOtp,
|
||||||
@@ -32,7 +36,15 @@ import {
|
|||||||
verifyEmailOtp,
|
verifyEmailOtp,
|
||||||
getStudyUserFromRequest,
|
getStudyUserFromRequest,
|
||||||
} from '../study-helpers.js'
|
} 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({
|
const studyAuthRateLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
@@ -54,6 +66,7 @@ export function register(app) {
|
|||||||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||||||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
||||||
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
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))),
|
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
|
||||||
twoFaMethod: user?.twoFaMethod ?? null,
|
twoFaMethod: user?.twoFaMethod ?? null,
|
||||||
totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0,
|
totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0,
|
||||||
@@ -87,6 +100,7 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
const rawVerifyToken = randomUUID()
|
||||||
const user = {
|
const user = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
username,
|
username,
|
||||||
@@ -95,6 +109,11 @@ export function register(app) {
|
|||||||
subscribeNewsletter: subscribe,
|
subscribeNewsletter: subscribe,
|
||||||
studyRemindersEnabled: false,
|
studyRemindersEnabled: false,
|
||||||
pendingEmailChange: null,
|
pendingEmailChange: null,
|
||||||
|
emailVerified: false,
|
||||||
|
pendingEmailVerification: {
|
||||||
|
tokenHash: hashEmailVerifyToken(rawVerifyToken),
|
||||||
|
expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS,
|
||||||
|
},
|
||||||
enrolledStudySlugs: [],
|
enrolledStudySlugs: [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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))
|
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)
|
const sessionToken = createStudySession(user.id)
|
||||||
setStudySessionCookie(res, sessionToken)
|
setStudySessionCookie(res, sessionToken)
|
||||||
@@ -356,11 +377,145 @@ export function register(app) {
|
|||||||
const token = cookies[STUDY_SESSION_COOKIE]
|
const token = cookies[STUDY_SESSION_COOKIE]
|
||||||
if (token) {
|
if (token) {
|
||||||
state.studySessions.delete(token)
|
state.studySessions.delete(token)
|
||||||
|
queueStudySessionsWrite()
|
||||||
}
|
}
|
||||||
clearStudySessionCookie(res)
|
clearStudySessionCookie(res)
|
||||||
res.json({ ok: true })
|
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)
|
// One-click unsubscribe from re-engagement emails (no login required)
|
||||||
app.get('/api/study-auth/unsubscribe-reminders', (req, res) => {
|
app.get('/api/study-auth/unsubscribe-reminders', (req, res) => {
|
||||||
const { uid, token } = req.query ?? {}
|
const { uid, token } = req.query ?? {}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export const state = {
|
|||||||
studyProgressWriteQueues: new Map(), // userId -> Promise
|
studyProgressWriteQueues: new Map(), // userId -> Promise
|
||||||
|
|
||||||
studySessions: new Map(),
|
studySessions: new Map(),
|
||||||
|
studySessionsWritePromise: Promise.resolve(),
|
||||||
studyTotpPendingTokens: new Map(),
|
studyTotpPendingTokens: new Map(),
|
||||||
emailOtpStore: new Map(),
|
emailOtpStore: new Map(),
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -22,7 +22,7 @@ import {
|
|||||||
DOWNLOAD_TOKEN_TTL_MS,
|
DOWNLOAD_TOKEN_TTL_MS,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
import { state } from './state.js'
|
import { state } from './state.js'
|
||||||
import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js'
|
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite } from './data.js'
|
||||||
|
|
||||||
// ── Username / slug normalizers ────────────────────────────────────────────
|
// ── Username / slug normalizers ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -64,6 +64,14 @@ export function hashEmailChangeToken(token) {
|
|||||||
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
|
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 ───────────────────────────────────────────
|
// ── Catalog / enrollment helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
export function getStudyCatalog() {
|
export function getStudyCatalog() {
|
||||||
@@ -144,9 +152,22 @@ export function getStudyAvatarUrl(subject) {
|
|||||||
export function createStudySession(userId) {
|
export function createStudySession(userId) {
|
||||||
const token = randomUUID()
|
const token = randomUUID()
|
||||||
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
||||||
|
queueStudySessionsWrite()
|
||||||
return token
|
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) {
|
export function setStudySessionCookie(res, token) {
|
||||||
res.append(
|
res.append(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
|
|||||||
@@ -481,6 +481,24 @@ function EmailTemplatesPanel({
|
|||||||
fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']}
|
fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']}
|
||||||
openKey="email-change" />
|
openKey="email-change" />
|
||||||
|
|
||||||
|
<EmailTemplateCard type="transactional" icon="✅" title="Account Security — Verify Email Address"
|
||||||
|
trigger="A student creates a study account (sent alongside the welcome email)"
|
||||||
|
autoVars="Student's display name, button links to a one-time verification URL (auto-generated, expires in 7 days, not editable)"
|
||||||
|
fieldKeys={['emailVerifySubject','emailVerifyBody','emailVerifyCtaLabel']}
|
||||||
|
openKey="email-verify" />
|
||||||
|
|
||||||
|
<EmailTemplateCard type="transactional" icon="🔑" title="Account Security — Password Reset"
|
||||||
|
trigger="A student requests a password reset from the sign-in page"
|
||||||
|
autoVars="Student's display name, button links to a one-time reset URL (auto-generated, expires in 1 hour, not editable)"
|
||||||
|
fieldKeys={['passwordResetSubject','passwordResetBody','passwordResetCtaLabel']}
|
||||||
|
openKey="password-reset" />
|
||||||
|
|
||||||
|
<EmailTemplateCard type="transactional" icon="🛡️" title="Account Security — Password Changed"
|
||||||
|
trigger="A student's password is changed (self-service reset, in-account change, or admin reset) — confirms the change and that other sessions were signed out"
|
||||||
|
autoVars="Student's display name, link to account settings"
|
||||||
|
fieldKeys={['passwordChangedSubject','passwordChangedBody']}
|
||||||
|
openKey="password-changed" />
|
||||||
|
|
||||||
<EmailTemplateCard type="transactional" icon="📟" title="Two-Factor Auth — Email Sign-In Code"
|
<EmailTemplateCard type="transactional" icon="📟" title="Two-Factor Auth — Email Sign-In Code"
|
||||||
trigger="A student with email-based 2FA signs in, or requests a resend during login"
|
trigger="A student with email-based 2FA signs in, or requests a resend during login"
|
||||||
autoVars="The 6-digit code is auto-generated and inserted between the body text and expiry note — not editable"
|
autoVars="The 6-digit code is auto-generated and inserted between the body text and expiry note — not editable"
|
||||||
@@ -1004,6 +1022,14 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
|
|||||||
{ key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' },
|
{ key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' },
|
||||||
{ key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
{ key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
||||||
{ key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' },
|
{ key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||||||
|
{ key: 'emailVerifySubject', label: 'Subject Line', section: 'email-templates' },
|
||||||
|
{ key: 'emailVerifyBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
||||||
|
{ key: 'emailVerifyCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||||||
|
{ key: 'passwordResetSubject', label: 'Subject Line', section: 'email-templates' },
|
||||||
|
{ key: 'passwordResetBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
||||||
|
{ key: 'passwordResetCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||||||
|
{ key: 'passwordChangedSubject', label: 'Subject Line', section: 'email-templates' },
|
||||||
|
{ key: 'passwordChangedBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
||||||
{ key: 'twoFaOtpEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
{ key: 'twoFaOtpEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||||||
{ key: 'twoFaOtpEmailBody', label: 'Body Text (shown above the code)', multiline: true, section: 'email-templates' },
|
{ key: 'twoFaOtpEmailBody', label: 'Body Text (shown above the code)', multiline: true, section: 'email-templates' },
|
||||||
{ key: 'twoFaOtpEmailExpiry', label: 'Expiry / Security Note (shown below the code)', multiline: true, section: 'email-templates' },
|
{ key: 'twoFaOtpEmailExpiry', label: 'Expiry / Security Note (shown below the code)', multiline: true, section: 'email-templates' },
|
||||||
|
|||||||
+7
-1
@@ -4,7 +4,7 @@ import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } fro
|
|||||||
import AdminPage from './AdminPage'
|
import AdminPage from './AdminPage'
|
||||||
import QASection from './components/QASection'
|
import QASection from './components/QASection'
|
||||||
import ContactForm from './components/ContactForm'
|
import ContactForm from './components/ContactForm'
|
||||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
|
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage, StudyResetPasswordPage, StudyVerifyEmailPage } from './studyPages'
|
||||||
import { SpotifyIcon } from './icons'
|
import { SpotifyIcon } from './icons'
|
||||||
import type { SiteContent, StudyProgram, Testimonial } from './content'
|
import type { SiteContent, StudyProgram, Testimonial } from './content'
|
||||||
import { DEFAULTS } from './content'
|
import { DEFAULTS } from './content'
|
||||||
@@ -386,6 +386,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished') || location.pathname.startsWith('/nuggets')
|
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished') || location.pathname.startsWith('/nuggets')
|
||||||
const studiesActive = location.pathname.startsWith('/study') || location.pathname === '/scripture'
|
const studiesActive = location.pathname.startsWith('/study') || location.pathname === '/scripture'
|
||||||
|
const communityStudy = getHomepageFeaturedStudy(content)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleOutsideClick(e: MouseEvent) {
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
@@ -444,6 +445,9 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
|||||||
<div className="nav-dropdown-menu">
|
<div className="nav-dropdown-menu">
|
||||||
<div className="nav-dropdown-menu-inner">
|
<div className="nav-dropdown-menu-inner">
|
||||||
<NavLink to="/study" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies</NavLink>
|
<NavLink to="/study" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies</NavLink>
|
||||||
|
{communityStudy && (
|
||||||
|
<NavLink to={`/study/${communityStudy.slug}/community`} className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Discussion</NavLink>
|
||||||
|
)}
|
||||||
<NavLink to="/scripture" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index</NavLink>
|
<NavLink to="/scripture" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3209,6 +3213,8 @@ export default function App() {
|
|||||||
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
||||||
<Route path="/study" element={<StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />} />
|
<Route path="/study" element={<StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />} />
|
||||||
<Route path="/study/signup" element={<StudyRouteFrame content={content} child={<StudySignupPage />} />} />
|
<Route path="/study/signup" element={<StudyRouteFrame content={content} child={<StudySignupPage />} />} />
|
||||||
|
<Route path="/study/reset-password" element={<StudyRouteFrame content={content} child={<StudyResetPasswordPage />} />} />
|
||||||
|
<Route path="/study/verify-email" element={<StudyRouteFrame content={content} child={<StudyVerifyEmailPage />} />} />
|
||||||
<Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
|
<Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
|
||||||
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
|
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
|
||||||
<Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} />
|
<Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} />
|
||||||
|
|||||||
+21
-2
@@ -1,5 +1,5 @@
|
|||||||
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
|
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData'
|
||||||
import type { ColossiansStudySection as ColossiansStudySectionData } from './colossiansStudyData'
|
import type { ColossiansStudySection as ColossiansStudySectionData } from './legacyColossiansData'
|
||||||
|
|
||||||
export type ColossiansStudySection = ColossiansStudySectionData
|
export type ColossiansStudySection = ColossiansStudySectionData
|
||||||
export type StudySection = ColossiansStudySectionData
|
export type StudySection = ColossiansStudySectionData
|
||||||
@@ -296,6 +296,17 @@ export interface SiteContent {
|
|||||||
emailChangeSubject: string
|
emailChangeSubject: string
|
||||||
emailChangeBody: string
|
emailChangeBody: string
|
||||||
emailChangeCtaLabel: 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 ──
|
// ── 2FA Email OTP ──
|
||||||
twoFaOtpEmailSubject: string
|
twoFaOtpEmailSubject: string
|
||||||
twoFaOtpEmailBody: string
|
twoFaOtpEmailBody: string
|
||||||
@@ -548,6 +559,14 @@ export const DEFAULTS: SiteContent = {
|
|||||||
emailChangeSubject: 'Confirm your new email address',
|
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.',
|
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',
|
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',
|
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.',
|
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.',
|
twoFaOtpEmailExpiry: 'This code expires in 10 minutes. If you did not request this, you can ignore this message.',
|
||||||
|
|||||||
File renamed without changes.
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import type { SiteContent, StudyProgram, StudySection } from './content'
|
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 { usePageMeta } from './hooks/usePageMeta'
|
||||||
import { Breadcrumbs } from './components/Breadcrumbs'
|
import { Breadcrumbs } from './components/Breadcrumbs'
|
||||||
import { StudyCertificate } from './components/StudyCertificate'
|
import { StudyCertificate } from './components/StudyCertificate'
|
||||||
@@ -23,6 +23,7 @@ type StudyAuthState = {
|
|||||||
totpEnabled?: boolean
|
totpEnabled?: boolean
|
||||||
twoFaMethod?: 'app' | 'email' | null
|
twoFaMethod?: 'app' | 'email' | null
|
||||||
totpRecoveryCodesRemaining?: number
|
totpRecoveryCodesRemaining?: number
|
||||||
|
emailVerified?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudyAuthStatusResponse = {
|
type StudyAuthStatusResponse = {
|
||||||
@@ -36,6 +37,7 @@ type StudyAuthStatusResponse = {
|
|||||||
totpEnabled?: boolean
|
totpEnabled?: boolean
|
||||||
twoFaMethod?: 'app' | 'email' | null
|
twoFaMethod?: 'app' | 'email' | null
|
||||||
totpRecoveryCodesRemaining?: number
|
totpRecoveryCodesRemaining?: number
|
||||||
|
emailVerified?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudyAccountOverview = {
|
type StudyAccountOverview = {
|
||||||
@@ -47,9 +49,12 @@ type StudyAccountOverview = {
|
|||||||
reminderFrequency?: string
|
reminderFrequency?: string
|
||||||
remindersPausedUntil?: string | null
|
remindersPausedUntil?: string | null
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
emailVerified?: boolean
|
||||||
}
|
}
|
||||||
stats: {
|
stats: {
|
||||||
noteCount: number
|
noteCount: number
|
||||||
|
noteLimit: number
|
||||||
|
noteCharLimit: number
|
||||||
memberSince: string
|
memberSince: string
|
||||||
lastLoginAt: string | null
|
lastLoginAt: string | null
|
||||||
currentStreak: number
|
currentStreak: number
|
||||||
@@ -65,6 +70,12 @@ type StudyAccountOverview = {
|
|||||||
completedLessons: number
|
completedLessons: number
|
||||||
noteCount: number
|
noteCount: number
|
||||||
}>
|
}>
|
||||||
|
certificates: Array<{
|
||||||
|
studySlug: string
|
||||||
|
studyTitle: string
|
||||||
|
issuedAt: string
|
||||||
|
url: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudyNotesMap = Record<string, string>
|
type StudyNotesMap = Record<string, string>
|
||||||
@@ -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() {
|
export function StudySignupPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [mode, setMode] = useState<'signup' | 'login'>('login')
|
const [mode, setMode] = useState<'signup' | 'login' | 'forgot'>('login')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
|
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
|
||||||
@@ -1052,6 +1063,8 @@ export function StudySignupPage() {
|
|||||||
const [totpCode, setTotpCode] = useState('')
|
const [totpCode, setTotpCode] = useState('')
|
||||||
const [totpMethod, setTotpMethod] = useState<'app' | 'email' | null>(null)
|
const [totpMethod, setTotpMethod] = useState<'app' | 'email' | null>(null)
|
||||||
const [resendStatus, setResendStatus] = useState<'idle' | 'sending' | 'sent'>('idle')
|
const [resendStatus, setResendStatus] = useState<'idle' | 'sending' | 'sent'>('idle')
|
||||||
|
// Forgot-password state
|
||||||
|
const [forgotSent, setForgotSent] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
|
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
|
||||||
@@ -1116,11 +1129,32 @@ export function StudySignupPage() {
|
|||||||
}
|
}
|
||||||
}, [totpPendingToken, totpCode, email])
|
}, [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 (
|
return (
|
||||||
<main className="thanks-page" aria-label={mode === 'signup' ? 'Create account' : 'Sign in'}>
|
<main className="thanks-page" aria-label={mode === 'signup' ? 'Create account' : mode === 'forgot' ? 'Reset password' : 'Sign in'}>
|
||||||
<div className="thanks-card study-signup-page-card">
|
<div className="thanks-card study-signup-page-card">
|
||||||
<p className="eyebrow">Free Student Account</p>
|
<p className="eyebrow">Free Student Account</p>
|
||||||
<h1>{mode === 'signup' ? 'Create Your Account' : 'Welcome Back'}</h1>
|
<h1>{mode === 'signup' ? 'Create Your Account' : mode === 'forgot' ? 'Reset Your Password' : 'Welcome Back'}</h1>
|
||||||
|
|
||||||
{done ? (
|
{done ? (
|
||||||
<>
|
<>
|
||||||
@@ -1131,6 +1165,41 @@ export function StudySignupPage() {
|
|||||||
<Link to="/study/account" className="btn-secondary" style={{ justifyContent: 'center' }}>My Account</Link>
|
<Link to="/study/account" className="btn-secondary" style={{ justifyContent: 'center' }}>My Account</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : mode === 'forgot' ? (
|
||||||
|
<div className="study-signup-form">
|
||||||
|
{forgotSent ? (
|
||||||
|
<>
|
||||||
|
<p className="study-signup-success">If that email has an account, a reset link is on its way.</p>
|
||||||
|
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Check your inbox and follow the link to choose a new password. The link expires in 1 hour.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ color: '#a89a6a', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
|
||||||
|
Enter the email address on your account and we'll send you a link to reset your password.
|
||||||
|
</p>
|
||||||
|
<label htmlFor="forgot-email">Email</label>
|
||||||
|
<input
|
||||||
|
id="forgot-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => e.key === 'Enter' && submitForgot()}
|
||||||
|
/>
|
||||||
|
{message && <p className="study-signup-error">{message}</p>}
|
||||||
|
<div className="study-signup-actions">
|
||||||
|
<button type="button" className="btn-primary" disabled={busy} onClick={submitForgot}>
|
||||||
|
{busy ? 'Sending…' : 'Send Reset Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="study-signup-actions" style={{ marginTop: '1rem' }}>
|
||||||
|
<button type="button" className="btn-secondary" onClick={() => { setMode('login'); setMessage(''); setForgotSent(false) }}>Back to Sign In</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : totpPendingToken ? (
|
) : totpPendingToken ? (
|
||||||
<div className="study-signup-form">
|
<div className="study-signup-form">
|
||||||
{totpMethod === 'email' ? (
|
{totpMethod === 'email' ? (
|
||||||
@@ -1220,6 +1289,11 @@ export function StudySignupPage() {
|
|||||||
onKeyDown={e => e.key === 'Enter' && submit()}
|
onKeyDown={e => e.key === 'Enter' && submit()}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.password && <p className="study-signup-field-error">{fieldErrors.password}</p>}
|
{fieldErrors.password && <p className="study-signup-field-error">{fieldErrors.password}</p>}
|
||||||
|
{mode === 'login' && (
|
||||||
|
<button type="button" onClick={() => { setMode('forgot'); setMessage(''); setForgotSent(false) }} style={{ background: 'none', border: 'none', color: '#c9a84c', cursor: 'pointer', fontSize: '0.85rem', textDecoration: 'underline', padding: 0, textAlign: 'left', marginTop: '0.5rem', alignSelf: 'flex-start' }}>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{mode === 'signup' && (
|
{mode === 'signup' && (
|
||||||
<label className="contact-consent" style={{ marginTop: '0.75rem' }}>
|
<label className="contact-consent" style={{ marginTop: '0.75rem' }}>
|
||||||
<input
|
<input
|
||||||
@@ -1250,6 +1324,7 @@ export function StudySignupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mode === 'signup' && (
|
||||||
<div className="study-signup-why-grid" style={{ marginTop: '2rem' }}>
|
<div className="study-signup-why-grid" style={{ marginTop: '2rem' }}>
|
||||||
<article className="study-signup-why-card">
|
<article className="study-signup-why-card">
|
||||||
<p className="study-signup-why-icon" aria-hidden="true">📝</p>
|
<p className="study-signup-why-icon" aria-hidden="true">📝</p>
|
||||||
@@ -1272,6 +1347,7 @@ export function StudySignupPage() {
|
|||||||
<p>Just an email and password. No charge, no ads. Stay signed in 30 days.</p>
|
<p>Just an email and password. No charge, no ads. Stay signed in 30 days.</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -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 (
|
||||||
|
<main className="thanks-page" aria-label="Reset password">
|
||||||
|
<div className="thanks-card study-signup-page-card">
|
||||||
|
<p className="eyebrow">Free Student Account</p>
|
||||||
|
<h1>Reset Your Password</h1>
|
||||||
|
|
||||||
|
{!token ? (
|
||||||
|
<p className="study-signup-error">This link is missing its token. Please use the link from your email, or request a new one.</p>
|
||||||
|
) : done ? (
|
||||||
|
<>
|
||||||
|
<p className="study-signup-success">Your password has been reset.</p>
|
||||||
|
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>You've been signed out of all devices for security. Sign in below with your new password.</p>
|
||||||
|
<button type="button" className="btn-primary" onClick={() => navigate('/study/signup')}>Sign In</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="study-signup-form">
|
||||||
|
<label htmlFor="reset-new-password">New Password</label>
|
||||||
|
<input
|
||||||
|
id="reset-new-password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<label htmlFor="reset-confirm-password">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
id="reset-confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Re-enter new password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && submit()}
|
||||||
|
/>
|
||||||
|
{message && <p className="study-signup-error">{message}</p>}
|
||||||
|
<div className="study-signup-actions">
|
||||||
|
<button type="button" className="btn-primary" disabled={busy} onClick={submit}>
|
||||||
|
{busy ? 'Resetting…' : 'Reset Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
|
||||||
|
<Link to="/study/signup" className="btn-secondary">Back to Sign In</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<main className="thanks-page" aria-label="Verify email">
|
||||||
|
<div className="thanks-card study-signup-page-card">
|
||||||
|
<p className="eyebrow">Free Student Account</p>
|
||||||
|
<h1>Verify Your Email</h1>
|
||||||
|
|
||||||
|
{status === 'checking' && <p style={{ color: '#b09e79' }}>Verifying…</p>}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<>
|
||||||
|
<p className="study-signup-success">Your email address is verified.</p>
|
||||||
|
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Thanks for confirming — you're all set.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'error' && <p className="study-signup-error">{message}</p>}
|
||||||
|
|
||||||
|
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
|
||||||
|
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
||||||
|
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ColossiansStudyIndexPage({ content }: Props) {
|
export function ColossiansStudyIndexPage({ content }: Props) {
|
||||||
const { studySlug } = useParams<{ studySlug?: string }>()
|
const { studySlug } = useParams<{ studySlug?: string }>()
|
||||||
const studies = getStudies(content)
|
const studies = getStudies(content)
|
||||||
@@ -2589,6 +2793,11 @@ export function StudyAccountPage() {
|
|||||||
const [pwSuccess, setPwSuccess] = useState(false)
|
const [pwSuccess, setPwSuccess] = useState(false)
|
||||||
|
|
||||||
const [signOutBusy, setSignOutBusy] = 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 [deleteStep, setDeleteStep] = useState<'idle' | 'warn1' | 'warn2'>('idle')
|
||||||
const [deleteConfirmInput, setDeleteConfirmInput] = useState('')
|
const [deleteConfirmInput, setDeleteConfirmInput] = useState('')
|
||||||
@@ -2611,6 +2820,7 @@ export function StudyAccountPage() {
|
|||||||
totpEnabled: data.totpEnabled === true,
|
totpEnabled: data.totpEnabled === true,
|
||||||
twoFaMethod: data.twoFaMethod ?? null,
|
twoFaMethod: data.twoFaMethod ?? null,
|
||||||
totpRecoveryCodesRemaining: data.totpRecoveryCodesRemaining ?? 0,
|
totpRecoveryCodesRemaining: data.totpRecoveryCodesRemaining ?? 0,
|
||||||
|
emailVerified: data.emailVerified === true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(() => setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: 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() {
|
async function handleDeleteAccount() {
|
||||||
setDeleteBusy(true)
|
setDeleteBusy(true)
|
||||||
setDeleteMessage('')
|
setDeleteMessage('')
|
||||||
@@ -2918,6 +3154,18 @@ export function StudyAccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{auth.emailVerified === false && (
|
||||||
|
<div style={{ marginBottom: '1.5rem', background: 'rgba(201,168,76,0.07)', border: '1px solid rgba(201,168,76,0.2)', borderRadius: '8px', padding: '0.75rem 1rem', fontSize: '0.85rem', color: '#a89a6a', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||||
|
<span><strong style={{ color: '#c9a84c' }}>Verify your email.</strong> We sent a link to {auth.username} — click it to confirm this address.</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
{resendVerifyMessage && <span style={{ fontSize: '0.8rem' }}>{resendVerifyMessage}</span>}
|
||||||
|
<button type="button" className="btn-secondary" style={{ fontSize: '0.82rem', padding: '0.3rem 0.75rem' }} disabled={resendVerifyBusy} onClick={handleResendVerification}>
|
||||||
|
{resendVerifyBusy ? 'Sending…' : 'Resend Email'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{overview?.stats && (
|
{overview?.stats && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
|
||||||
@@ -2957,6 +3205,31 @@ export function StudyAccountPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{overview.stats.noteLimit > 0 && (
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem', color: '#7a7060' }}>
|
||||||
|
<span style={{ textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600 }}>Note Usage</span>
|
||||||
|
<span>{overview.stats.noteCount}/{overview.stats.noteLimit} notes · up to {overview.stats.noteCharLimit.toLocaleString()} characters each</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: '6px', background: '#2a2518', borderRadius: '3px', overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', width: `${Math.min(100, Math.round((overview.stats.noteCount / overview.stats.noteLimit) * 100))}%`, background: '#c9a84c', borderRadius: '3px', transition: 'width 0.4s ease' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{overview.certificates.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: '#7a7060' }}>Certificates</p>
|
||||||
|
{overview.certificates.map(cert => (
|
||||||
|
<div key={cert.studySlug} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.6rem', fontSize: '0.88rem' }}>
|
||||||
|
<span>🏅 {cert.studyTitle} <span style={{ color: '#7a7060', fontSize: '0.82rem' }}>— {new Date(cert.issuedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span></span>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<a href={cert.url} target="_blank" rel="noopener noreferrer" className="btn-admin-ghost" style={{ fontSize: '0.78rem', padding: '0.2rem 0.6rem' }}>View</a>
|
||||||
|
<button type="button" className="btn-admin-ghost" style={{ fontSize: '0.78rem', padding: '0.2rem 0.6rem' }} onClick={() => { navigator.clipboard?.writeText(cert.url).catch(() => {}) }}>Copy Link</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{divider}
|
{divider}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -3451,9 +3724,15 @@ export function StudyAccountPage() {
|
|||||||
|
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ marginTop: 0 }}>Sign Out</h2>
|
<h2 style={{ marginTop: 0 }}>Sign Out</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<button type="button" className="btn-secondary" disabled={signOutBusy} onClick={handleSignOut}>
|
<button type="button" className="btn-secondary" disabled={signOutBusy} onClick={handleSignOut}>
|
||||||
{signOutBusy ? 'Signing out...' : 'Sign Out'}
|
{signOutBusy ? 'Signing out...' : 'Sign Out'}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn-secondary" disabled={signOutAllBusy} onClick={handleSignOutAllDevices}>
|
||||||
|
{signOutAllBusy ? 'Signing out other devices…' : 'Sign Out of All Other Devices'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{signOutAllMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{signOutAllMessage}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{divider}
|
{divider}
|
||||||
Reference in new issue
Block a user