diff --git a/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json b/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json index 3baee1a..2bf0669 100644 --- a/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json +++ b/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json @@ -2,7 +2,8 @@ "byStudy": { "colossians": { "completedSectionIds": [ - "1-1-2" + "1-1-2", + "1-3-8" ], "quizAnswers": { "1-1-2": [ @@ -12,5 +13,5 @@ } } }, - "updatedAt": "2026-06-02T17:53:29.839Z" + "updatedAt": "2026-06-03T17:48:33.375Z" } \ No newline at end of file diff --git a/data/study-users.json b/data/study-users.json index 909170d..9b022c5 100644 --- a/data/study-users.json +++ b/data/study-users.json @@ -23,9 +23,10 @@ "colossians" ], "avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E", - "lastLoginAt": "2026-06-03T14:16:38.510Z", + "lastLoginAt": "2026-06-03T17:47:53.877Z", "pendingEmailChange": null, - "updatedAt": "2026-06-03T14:16:38.510Z" + "updatedAt": "2026-06-03T17:47:59.104Z", + "totpSecretPending": "FGR7UI5TWA4HB4DURCXEUXATTVVVJDJ5" }, { "id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a", @@ -40,5 +41,5 @@ "pendingEmailChange": null } ], - "updatedAt": "2026-06-03T14:16:38.513Z" + "updatedAt": "2026-06-03T17:47:59.104Z" } \ No newline at end of file diff --git a/server.js b/server.js index 6e4c5cd..f4c452e 100644 --- a/server.js +++ b/server.js @@ -656,6 +656,50 @@ let lastBackupStatus = { ok: true, at: null, error: null, file: null } let lastCachePurgeStatus = { ok: true, at: null, error: null } let lastDeployHookStatus = { ok: true, at: null, error: null } const studySessions = new Map() +// Short-lived tokens for 2FA second step: token → { userId, expiresAt } +const studyTotpPendingTokens = new Map() +const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000 // 5 minutes +// In-memory email OTP store: userId → { codeHash, expiresAt, attempts } +const emailOtpStore = new Map() +const EMAIL_OTP_TTL_MS = 10 * 60 * 1000 // 10 minutes +const EMAIL_OTP_MAX_ATTEMPTS = 5 + +function generateEmailOtp() { + return String(Math.floor(100000 + Math.random() * 900000)) +} + +function hashEmailOtp(code) { + return createHash('sha256').update(String(code).trim()).digest('hex') +} + +function storeEmailOtp(userId, code) { + emailOtpStore.set(userId, { codeHash: hashEmailOtp(code), expiresAt: Date.now() + EMAIL_OTP_TTL_MS, attempts: 0 }) +} + +function verifyEmailOtp(userId, code) { + const entry = emailOtpStore.get(userId) + if (!entry) return 'no-code' + if (Date.now() > entry.expiresAt) { emailOtpStore.delete(userId); return 'expired' } + entry.attempts += 1 + if (entry.attempts > EMAIL_OTP_MAX_ATTEMPTS) { emailOtpStore.delete(userId); return 'too-many' } + if (hashEmailOtp(String(code).trim()) !== entry.codeHash) return 'wrong' + emailOtpStore.delete(userId) + return 'ok' +} + +function createStudyTotpPendingToken(userId) { + const token = randomUUID() + studyTotpPendingTokens.set(token, { userId, expiresAt: Date.now() + STUDY_TOTP_PENDING_TTL_MS }) + return token +} + +function consumeStudyTotpPendingToken(token) { + const entry = studyTotpPendingTokens.get(token) + if (!entry) return null + studyTotpPendingTokens.delete(token) + if (Date.now() > entry.expiresAt) return null + return entry.userId +} function sanitizeReplyTemplates(value) { if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES] @@ -2470,6 +2514,34 @@ async function sendStudyWelcomeEmail(email, displayName) { } } +async function sendEmailOtp(email, code) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = cachedSiteContent ?? {} + const subject = cfg.twoFaOtpEmailSubject?.trim() || 'Your sign-in code — Verse by Verse with Nate' + const bodyText = cfg.twoFaOtpEmailBody?.trim() || 'Your two-factor sign-in code is below. Enter it to complete sign-in.' + const expiryText = cfg.twoFaOtpEmailExpiry?.trim() || 'This code expires in 10 minutes. If you did not request this, you can ignore this message.' + await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `${bodyText}\n\n${code}\n\n${expiryText}\n\nVerse by Verse with Nate`, + html: buildBrandedEmailHtml({ + title: 'Your Sign-In Code', + eyebrow: 'Account Security', + bodyHtml: + `

${escapeHtml(bodyText)}

` + + `

${code}

` + + `

${escapeHtml(expiryText)}

`, + footerHtml: `

Verse by Verse with Nate

`, + }), + }) + } catch (err) { + console.error('[email-otp] send error:', err) + } +} + async function sendStudyAccountDeletedEmail(email, displayName) { if (!process.env.RESEND_API_KEY) return try { @@ -3004,6 +3076,9 @@ app.get('/api/study-auth/status', (req, res) => { studyRemindersEnabled: user?.studyRemindersEnabled === true, avatarUrl: user ? getStudyAvatarUrl(user) : '', enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [], + 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, }) }) @@ -3088,6 +3163,22 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => { return } + // If 2FA is enabled, pause here and return a pending token + const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null) + if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) { + const pendingToken = createStudyTotpPendingToken(user.id) + res.json({ totpRequired: true, pendingToken, method: 'app' }) + return + } + if (twoFaMethod === 'email') { + const code = generateEmailOtp() + storeEmailOtp(user.id, code) + const pendingToken = createStudyTotpPendingToken(user.id) + sendEmailOtp(user.username, code).catch(err => console.error('[email-otp] login send error:', err)) + res.json({ totpRequired: true, pendingToken, method: 'email' }) + return + } + user.lastLoginAt = new Date().toISOString() user.updatedAt = user.lastLoginAt queueStudyUsersWrite() @@ -3105,6 +3196,182 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => { }) }) +// ── Study 2FA (TOTP) ───────────────────────────────────────────────────────── + +// Step 2 of login: verify TOTP code after password accepted +app.post('/api/study-auth/totp-verify', studyAuthRateLimiter, (req, res) => { + const { pendingToken, code } = req.body ?? {} + const userId = consumeStudyTotpPendingToken(pendingToken) + if (!userId) { + res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' }) + return + } + const user = studyUsers.find(u => u.id === userId) + if (!user || !user.totpSecret || !user.totpVerified) { + res.status(400).json({ message: '2FA is not configured for this account.' }) + return + } + + const codeStr = typeof code === 'string' ? code.replace(/\s/g, '') : '' + const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null) + + function completeLogin(extra = {}) { + user.lastLoginAt = new Date().toISOString() + user.updatedAt = user.lastLoginAt + queueStudyUsersWrite() + const sessionToken = createStudySession(user.id) + setStudySessionCookie(res, sessionToken) + res.json({ ok: true, ...extra, username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs ?? [] }) + } + + // Email OTP method + if (twoFaMethod === 'email') { + const result = verifyEmailOtp(user.id, codeStr) + if (result === 'ok') { completeLogin(); return } + if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please sign in again to receive a new code.' }); return } + if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please sign in again.' }); return } + res.status(401).json({ message: 'Invalid code. Check your email and try again.' }) + return + } + + // App (TOTP) method + if (verifyTotpCode(user.totpSecret, codeStr)) { + completeLogin() + return + } + + // Try recovery code + if (Array.isArray(user.totpRecoveryCodes) && user.totpRecoveryCodes.length > 0) { + const normalised = codeStr.replace(/-/g, '').toUpperCase() + const matchIdx = user.totpRecoveryCodes.findIndex(h => { + try { return createHash('sha256').update(normalised).digest('hex') === h } catch { return false } + }) + if (matchIdx !== -1) { + user.totpRecoveryCodes.splice(matchIdx, 1) + completeLogin({ usedRecoveryCode: true, remainingRecoveryCodes: user.totpRecoveryCodes.length }) + return + } + } + + res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' }) +}) + +// Begin 2FA setup: generate secret + QR code +app.post('/api/study-auth/totp-setup-init', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const secret = generateTotpSecret() + const label = user.username + const issuer = 'Verse by Verse with Nate' + const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30` + const qrDataUrl = await qrcode.toDataURL(uri) + // Store unverified secret temporarily on the user record + user.totpSecretPending = secret + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + res.json({ qrDataUrl, secret }) +}) + +// Confirm 2FA setup: verify first code then activate +app.post('/api/study-auth/totp-setup-confirm', requireStudyAuth, (req, res) => { + const user = req.studyUser + const { code } = req.body ?? {} + if (!user.totpSecretPending) { + res.status(400).json({ message: 'No 2FA setup in progress. Start setup first.' }) + return + } + if (!verifyTotpCode(user.totpSecretPending, typeof code === 'string' ? code.replace(/\s/g, '') : '')) { + res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' }) + return + } + const recoveryCodes = generateRecoveryCodes() + user.totpSecret = user.totpSecretPending + user.totpVerified = true + user.twoFaMethod = 'app' + user.totpEnabledAt = new Date().toISOString() + user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex')) + delete user.totpSecretPending + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + res.json({ ok: true, recoveryCodes }) +}) + +// Email 2FA setup — step 1: send verification code +app.post('/api/study-auth/2fa-setup-email', studyAuthRateLimiter, requireStudyAuth, async (req, res) => { + const user = req.studyUser + const code = generateEmailOtp() + storeEmailOtp(user.id, code) + await sendEmailOtp(user.username, code) + res.json({ ok: true }) +}) + +// Email 2FA setup — step 2: confirm code and activate +app.post('/api/study-auth/2fa-setup-email-confirm', studyAuthRateLimiter, requireStudyAuth, (req, res) => { + const user = req.studyUser + const { code } = req.body ?? {} + const result = verifyEmailOtp(user.id, typeof code === 'string' ? code.trim() : '') + if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please request a new one.' }); return } + if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please request a new code.' }); return } + if (result !== 'ok') { res.status(401).json({ message: 'Invalid code. Check your email and try again.' }); return } + // Clear any app 2FA and switch to email + user.twoFaMethod = 'email' + user.totpSecret = null + user.totpVerified = false + user.totpRecoveryCodes = [] + delete user.totpSecretPending + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + res.json({ ok: true }) +}) + +// Resend email OTP during login (uses pending token to identify user) +app.post('/api/study-auth/email-otp-resend', studyAuthRateLimiter, async (req, res) => { + const { pendingToken } = req.body ?? {} + // Peek at the pending token without consuming it + const entry = studyTotpPendingTokens.get(pendingToken) + if (!entry || Date.now() > entry.expiresAt) { res.status(401).json({ message: 'Session expired. Please sign in again.' }); return } + const user = studyUsers.find(u => u.id === entry.userId) + if (!user) { res.status(404).json({ message: 'User not found.' }); return } + const code = generateEmailOtp() + storeEmailOtp(user.id, code) + await sendEmailOtp(user.username, code) + res.json({ ok: true }) +}) + +// Disable 2FA (requires current password confirmation) +app.post('/api/study-auth/totp-disable', studyAuthRateLimiter, requireStudyAuth, (req, res) => { + const user = req.studyUser + const { password } = req.body ?? {} + const submittedHash = hashStudyPassword(typeof password === 'string' ? password : '') + const a = Buffer.from(submittedHash, 'utf8') + const b = Buffer.from(user.passwordHash, 'utf8') + if (a.length !== b.length || !timingSafeEqual(a, b)) { + res.status(401).json({ message: 'Incorrect password.' }) + return + } + user.twoFaMethod = null + user.totpSecret = null + user.totpVerified = false + user.totpRecoveryCodes = [] + delete user.totpSecretPending + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + res.json({ ok: true }) +}) + +// Regenerate recovery codes (requires active session) +app.post('/api/study-auth/totp-regen-recovery', requireStudyAuth, (req, res) => { + const user = req.studyUser + if (!user.totpSecret || !user.totpVerified) { + res.status(400).json({ message: '2FA is not enabled.' }) + return + } + const recoveryCodes = generateRecoveryCodes() + user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex')) + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + res.json({ ok: true, recoveryCodes }) +}) + app.get('/api/study-enrollment', requireStudyAuth, (req, res) => { const user = req.studyUser res.json({ diff --git a/server/helpers.js b/server/helpers.js index 8eaca09..68761a7 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -359,6 +359,9 @@ export function sanitizeSiteContent(siteContent) { emailChangeSubject: typeof siteContent.emailChangeSubject === 'string' ? siteContent.emailChangeSubject.trim().slice(0, 200) : '', emailChangeBody: typeof siteContent.emailChangeBody === 'string' ? siteContent.emailChangeBody.trim().slice(0, 500) : '', emailChangeCtaLabel: typeof siteContent.emailChangeCtaLabel === 'string' ? siteContent.emailChangeCtaLabel.trim().slice(0, 80) : '', + twoFaOtpEmailSubject: typeof siteContent.twoFaOtpEmailSubject === 'string' ? siteContent.twoFaOtpEmailSubject.trim().slice(0, 200) : '', + twoFaOtpEmailBody: typeof siteContent.twoFaOtpEmailBody === 'string' ? siteContent.twoFaOtpEmailBody.trim().slice(0, 500) : '', + twoFaOtpEmailExpiry: typeof siteContent.twoFaOtpEmailExpiry === 'string' ? siteContent.twoFaOtpEmailExpiry.trim().slice(0, 300) : '', seo: { title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title, description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description, diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 6ffbe37..d2d32d7 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -461,6 +461,12 @@ function EmailTemplatesPanel({ fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']} openKey="email-change" /> + + {renderSaveStatus()} ) @@ -939,6 +945,9 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect { key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' }, { key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' }, { key: 'emailChangeCtaLabel', label: 'Button Label', 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: 'twoFaOtpEmailExpiry', label: 'Expiry / Security Note (shown below the code)', multiline: true, section: 'email-templates' }, ] function normalizeStudies(siteContent: SiteContent): StudyProgram[] { diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx index 573d2bc..4c1ef40 100644 --- a/src/colossiansStudy.tsx +++ b/src/colossiansStudy.tsx @@ -14,6 +14,9 @@ type StudyAuthState = { subscribeNewsletter?: boolean studyRemindersEnabled?: boolean avatarUrl?: string + totpEnabled?: boolean + twoFaMethod?: 'app' | 'email' | null + totpRecoveryCodesRemaining?: number } type StudyAuthStatusResponse = { @@ -24,6 +27,9 @@ type StudyAuthStatusResponse = { subscribeNewsletter?: boolean studyRemindersEnabled?: boolean avatarUrl?: string + totpEnabled?: boolean + twoFaMethod?: 'app' | 'email' | null + totpRecoveryCodesRemaining?: number } type StudyAccountOverview = { @@ -891,6 +897,11 @@ export function StudySignupPage() { const [message, setMessage] = useState('') const [done, setDone] = useState(false) const [loggedInAs, setLoggedInAs] = useState('') + // 2FA state + const [totpPendingToken, setTotpPendingToken] = useState('') + const [totpCode, setTotpCode] = useState('') + const [totpMethod, setTotpMethod] = useState<'app' | 'email' | null>(null) + const [resendStatus, setResendStatus] = useState<'idle' | 'sending' | 'sent'>('idle') useEffect(() => { readJson('/api/study-auth/status') @@ -911,11 +922,16 @@ export function StudySignupPage() { setBusy(true) setMessage('') try { - const data = await readJson<{ username: string; enrolledStudySlugs?: string[] }>(`/api/study-auth/${mode}`, { + const data = await readJson<{ username?: string; enrolledStudySlugs?: string[]; totpRequired?: boolean; pendingToken?: string }>(`/api/study-auth/${mode}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: email, password, ...(mode === 'signup' ? { subscribe: subscribeNewsletter } : {}) }), }) + if (data.totpRequired && data.pendingToken) { + setTotpPendingToken(data.pendingToken) + setTotpMethod((data as { method?: 'app' | 'email' }).method ?? 'app') + return + } setDone(true) setLoggedInAs(data.username ?? email.trim().toLowerCase()) navigate('/study') @@ -926,6 +942,26 @@ export function StudySignupPage() { } }, [mode, email, password, subscribeNewsletter]) + const submitTotp = useCallback(async () => { + if (!totpCode.trim()) { setMessage('Please enter your 6-digit code.'); return } + setBusy(true) + setMessage('') + try { + const data = await readJson<{ ok?: boolean; username?: string; usedRecoveryCode?: boolean }>('/api/study-auth/totp-verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pendingToken: totpPendingToken, code: totpCode }), + }) + setDone(true) + setLoggedInAs(data.username ?? email.trim().toLowerCase()) + navigate('/study') + } catch (err) { + setMessage(err instanceof Error ? err.message : 'Invalid code. Please try again.') + } finally { + setBusy(false) + } + }, [totpPendingToken, totpCode, email]) + return (
@@ -941,6 +977,56 @@ export function StudySignupPage() { Browse Tracks
+ ) : totpPendingToken ? ( +
+ {totpMethod === 'email' ? ( +

+ 📧 A 6-digit code was sent to your email address. Enter it below to sign in. +

+ ) : ( +

+ 🔐 Open your authenticator app and enter the 6-digit code below. +

+ )} + + setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + autoFocus + onKeyDown={e => e.key === 'Enter' && submitTotp()} + style={{ letterSpacing: '0.3em', fontSize: '1.4rem', textAlign: 'center' }} + /> + {message &&

{message}

} +
+ + +
+ {totpMethod === 'email' && ( +

+ Didn't get it?{' '} + +

+ )} + {totpMethod === 'app' && ( +

Lost access to your app? Enter one of your recovery codes instead.

+ )} +
) : ( <>
@@ -1265,7 +1351,7 @@ export function ColossiansStudySectionPage({ content }: Props) { const [progressSaving, setProgressSaving] = useState(false) const [progressMessage, setProgressMessage] = useState('') const [checkpointAnswers, setCheckpointAnswers] = useState>({}) - const [checkpointReflection, setCheckpointReflection] = useState('') + const [_checkpointReflection, _setCheckpointReflection] = useState('') const canSaveNote = auth.authenticated && !noteSaving && !noteLoading const lessonAudioEmbedUrl = useMemo(() => { @@ -1330,7 +1416,6 @@ export function ColossiansStudySectionPage({ content }: Props) { useEffect(() => { setCheckpointAnswers({}) - setCheckpointReflection('') }, [section?.id]) useEffect(() => { @@ -1440,15 +1525,10 @@ export function ColossiansStudySectionPage({ content }: Props) { async function submitCheckpoint() { if (!study || !section?.id || progressSaving) return const checkpointQuestions = section.checkpointQuestions ?? [] - const reflectionRequired = !(checkpointQuestions.length > 0) const allAnswered = checkpointQuestions.every((_, index) => (checkpointAnswers[index] ?? '').trim().length > 0) - if (reflectionRequired && !checkpointReflection.trim()) { - setProgressMessage('Please write a short reflection before submitting the checkpoint.') - return - } - if (!reflectionRequired && !allAnswered) { - setProgressMessage('Please answer all checkpoint questions before submitting.') + if (!allAnswered) { + setProgressMessage('Please answer all checkpoint questions to mark this lesson complete.') return } @@ -1459,9 +1539,9 @@ export function ColossiansStudySectionPage({ content }: Props) { method: 'POST', }) setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []) - setProgressMessage('Checkpoint submitted and lesson marked complete.') + setProgressMessage('') } catch (err) { - setProgressMessage(err instanceof Error ? err.message : 'Unable to submit checkpoint.') + setProgressMessage(err instanceof Error ? err.message : 'Unable to save. Please try again.') } finally { setProgressSaving(false) } @@ -1616,13 +1696,51 @@ export function ColossiansStudySectionPage({ content }: Props) {
- {(section.checkpointPrompt || (section.checkpointQuestions && section.checkpointQuestions.length > 0)) && ( -
-

Checkpoint

- {section.checkpointPrompt &&

{section.checkpointPrompt}

} - {section.checkpointQuestions && section.checkpointQuestions.length > 0 ? ( + {isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && ( +
+
+

Lesson Progress

+

No checkpoint questions for this lesson.

+
+ +
+ )} + + {(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (() => { + const allAnswered = section.checkpointQuestions!.every((_, i) => (checkpointAnswers[i] ?? '').trim().length > 0) + return ( +
+
+

Checkpoint

+ {lessonCompleted && ( + + ✓ Completed + + )} +
+ {section.checkpointPrompt &&

{section.checkpointPrompt}

}
- {section.checkpointQuestions.map((question, index) => ( + {section.checkpointQuestions!.map((question, index) => (

{question}