cool stuff

This commit is contained in:
nmemmert
2026-06-03 14:14:25 -04:00
parent a2c4924191
commit 68f6212e9b
7 changed files with 665 additions and 43 deletions
@@ -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"
}
+4 -3
View File
@@ -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"
}
+267
View File
@@ -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:
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
`<p style="margin:0 0 16px;font-size:2.5rem;font-weight:700;letter-spacing:0.35em;color:#c9a84c;font-family:monospace;">${code}</p>` +
`<p style="margin:0 0 16px;font-size:0.9rem;color:#7a7060;">${escapeHtml(expiryText)}</p>`,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
}),
})
} 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({
+3
View File
@@ -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,
+9
View File
@@ -461,6 +461,12 @@ function EmailTemplatesPanel({
fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']}
openKey="email-change" />
<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"
autoVars="The 6-digit code is auto-generated and inserted between the body text and expiry note — not editable"
fieldKeys={['twoFaOtpEmailSubject','twoFaOtpEmailBody','twoFaOtpEmailExpiry']}
openKey="2fa-otp" />
{renderSaveStatus()}
</section>
)
@@ -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[] {
+372 -38
View File
@@ -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<StudyAuthStatusResponse>('/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 (
<main className="thanks-page" aria-label={mode === 'signup' ? 'Create account' : 'Sign in'}>
<div className="thanks-card study-signup-page-card">
@@ -941,6 +977,56 @@ export function StudySignupPage() {
<Link to="/study" className="btn-secondary">Browse Tracks</Link>
</div>
</>
) : totpPendingToken ? (
<div className="study-signup-form">
{totpMethod === 'email' ? (
<p style={{ color: '#a89a6a', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
📧 A 6-digit code was sent to your email address. Enter it below to sign in.
</p>
) : (
<p style={{ color: '#a89a6a', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
🔐 Open your authenticator app and enter the 6-digit code below.
</p>
)}
<label htmlFor="totp-code">{totpMethod === 'email' ? 'Email Code' : 'Authenticator Code'}</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={e => 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 && <p className="study-signup-error">{message}</p>}
<div className="study-signup-actions">
<button type="button" className="btn-primary" disabled={busy} onClick={submitTotp}>
{busy ? 'Verifying' : 'Verify'}
</button>
<button type="button" className="btn-secondary" onClick={() => { setTotpPendingToken(''); setMessage(''); setTotpCode('') }}>Back</button>
</div>
{totpMethod === 'email' && (
<p style={{ fontSize: '0.82rem', color: '#5a5440', marginTop: '1rem' }}>
Didn't get it?{' '}
<button type="button" disabled={resendStatus !== 'idle'} style={{ background: 'none', border: 'none', color: resendStatus === 'sent' ? '#7abf7a' : '#c9a84c', cursor: resendStatus === 'idle' ? 'pointer' : 'default', fontSize: 'inherit', textDecoration: 'underline', padding: 0 }} onClick={async () => {
setResendStatus('sending')
try {
await fetch('/api/study-auth/email-otp-resend', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken: totpPendingToken }) })
setResendStatus('sent')
setTimeout(() => setResendStatus('idle'), 30000)
} catch { setResendStatus('idle') }
}}>
{resendStatus === 'sending' ? 'Sending…' : resendStatus === 'sent' ? 'Code sent ✓' : 'Resend code'}
</button>
</p>
)}
{totpMethod === 'app' && (
<p style={{ fontSize: '0.8rem', color: '#5a5440', marginTop: '1rem' }}>Lost access to your app? Enter one of your recovery codes instead.</p>
)}
</div>
) : (
<>
<div className="study-signup-form">
@@ -1265,7 +1351,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
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) {
</div>
</article>
{(section.checkpointPrompt || (section.checkpointQuestions && section.checkpointQuestions.length > 0)) && (
<article className="study-class-block">
<h2>Checkpoint</h2>
{section.checkpointPrompt && <p className="study-detail-copy">{section.checkpointPrompt}</p>}
{section.checkpointQuestions && section.checkpointQuestions.length > 0 ? (
{isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (
<article className="study-class-block" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<h2 style={{ margin: 0 }}>Lesson Progress</h2>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: '#5a5440' }}>No checkpoint questions for this lesson.</p>
</div>
<button
type="button"
className={lessonCompleted ? 'btn-secondary' : 'btn-primary'}
style={lessonCompleted ? { borderColor: '#2196f3', color: '#2196f3' } : {}}
disabled={progressSaving}
onClick={async () => {
if (!study || !section?.id) return
setProgressSaving(true)
try {
const method = lessonCompleted ? 'DELETE' : 'POST'
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
{ method }
)
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
} catch { /* ignore */ }
finally { setProgressSaving(false) }
}}
>
{progressSaving ? '' : lessonCompleted ? ' Completed' : 'Mark as Completed'}
</button>
</article>
)}
{(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (() => {
const allAnswered = section.checkpointQuestions!.every((_, i) => (checkpointAnswers[i] ?? '').trim().length > 0)
return (
<article className="study-class-block">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>Checkpoint</h2>
{lessonCompleted && (
<span style={{ background: '#1565c0', color: '#fff', padding: '0.2rem 0.65rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 600 }}>
✓ Completed
</span>
)}
</div>
{section.checkpointPrompt && <p className="study-detail-copy">{section.checkpointPrompt}</p>}
<div style={{ display: 'grid', gap: '1rem' }}>
{section.checkpointQuestions.map((question, index) => (
{section.checkpointQuestions!.map((question, index) => (
<div key={index}>
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{question}</p>
<textarea
@@ -1630,30 +1748,28 @@ export function ColossiansStudySectionPage({ content }: Props) {
value={checkpointAnswers[index] ?? ''}
onChange={e => setCheckpointAnswers(prev => ({ ...prev, [index]: e.target.value }))}
placeholder="Write your answer here..."
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: `1px solid ${(checkpointAnswers[index] ?? '').trim() ? '#2a4a2a' : '#2a2518'}`, background: '#14130f', color: '#f0ead8' }}
/>
</div>
))}
</div>
) : (
<div>
<textarea
rows={4}
value={checkpointReflection}
onChange={e => setCheckpointReflection(e.target.value)}
placeholder="Reflect on the prompt above and write your observations here..."
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
/>
<div style={{ marginTop: '1.25rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn-primary"
onClick={submitCheckpoint}
disabled={!isEnrolled || progressSaving || !allAnswered}
>
{progressSaving ? 'Saving' : lessonCompleted ? 'Update Answers' : 'Mark as Completed'}
</button>
{!allAnswered && (
<span style={{ fontSize: '0.85rem', color: '#5a5440' }}>Answer all questions to mark this lesson complete.</span>
)}
{progressMessage && <span style={{ fontSize: '0.85rem', color: '#e07a7a' }}>{progressMessage}</span>}
</div>
)}
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn-primary" onClick={submitCheckpoint} disabled={!isEnrolled || progressSaving}>
{progressSaving ? 'Submitting...' : lessonCompleted ? 'Re-submit Checkpoint' : 'Submit Checkpoint'}
</button>
{lessonCompleted && <span style={{ color: '#a39d8d' }}>Checkpoint completed for this lesson.</span>}
</div>
</article>
)}
</article>
)
})()}
</div>
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
@@ -1990,7 +2106,16 @@ export function StudyAccountPage() {
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
const [prefMessage, setPrefMessage] = useState('')
const [prefBusy, setPrefBusy] = useState(false)
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export'>('none')
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export' | 'choose2fa' | 'setup2fa' | 'setup2fa-email' | 'disable2fa' | 'recoveryCodes'>('none')
// 2FA state
const [twoFaQr, setTwoFaQr] = useState('')
const [twoFaSecret, setTwoFaSecret] = useState('')
const [twoFaCode, setTwoFaCode] = useState('')
const [twoFaMessage, setTwoFaMessage] = useState('')
const [twoFaBusy, setTwoFaBusy] = useState(false)
const [twoFaRecoveryCodes, setTwoFaRecoveryCodes] = useState<string[]>([])
const [disablePassword, setDisablePassword] = useState('')
const [emailCurrentPassword, setEmailCurrentPassword] = useState('')
const [emailNew, setEmailNew] = useState('')
@@ -2031,6 +2156,9 @@ export function StudyAccountPage() {
studyRemindersEnabled: data.studyRemindersEnabled === true,
avatarUrl: data.avatarUrl ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
totpEnabled: data.totpEnabled === true,
twoFaMethod: data.twoFaMethod ?? null,
totpRecoveryCodesRemaining: data.totpRecoveryCodesRemaining ?? 0,
})
})
.catch(() => setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true }))
@@ -2349,7 +2477,32 @@ export function StudyAccountPage() {
<button type="button" className="btn-primary" onClick={() => setAccountModal('enrollments')}>Manage Enrollments</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changeEmail')}>Change Email</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changePassword')}>Change Password</button>
<button type="button" className={auth.totpEnabled ? 'btn-secondary' : 'btn-primary'} onClick={() => {
if (auth.totpEnabled) { setAccountModal('disable2fa'); return }
setAccountModal('choose2fa')
}} disabled={twoFaBusy}>
{auth.totpEnabled
? `2FA: ${auth.twoFaMethod === 'email' ? 'Email ' : 'App '}`
: '🔐 Enable Two-Factor Auth'}
</button>
</div>
{!auth.totpEnabled && (
<div style={{ marginTop: '1rem', 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' }}>
<strong style={{ color: '#c9a84c' }}>Recommended:</strong> Enable two-factor authentication to protect your study notes and account.
</div>
)}
{auth.totpEnabled && auth.totpRecoveryCodesRemaining !== undefined && auth.totpRecoveryCodesRemaining <= 2 && (
<div style={{ marginTop: '1rem', background: 'rgba(224,92,92,0.07)', border: '1px solid rgba(224,92,92,0.2)', borderRadius: '8px', padding: '0.75rem 1rem', fontSize: '0.85rem', color: '#e07a7a' }}>
<strong>Warning:</strong> You only have {auth.totpRecoveryCodesRemaining} recovery code{auth.totpRecoveryCodesRemaining === 1 ? '' : 's'} left.{' '}
<button type="button" style={{ background: 'none', border: 'none', color: '#c9a84c', cursor: 'pointer', textDecoration: 'underline', fontSize: 'inherit' }} onClick={async () => {
try {
const data = await readJson<{ ok: boolean; recoveryCodes: string[] }>('/api/study-auth/totp-regen-recovery', { method: 'POST' })
setTwoFaRecoveryCodes(data.recoveryCodes)
setAccountModal('recoveryCodes')
} catch { /* ignore */ }
}}>Generate new codes →</button>
</div>
)}
</section>
{accountModal !== 'none' && (
@@ -2503,6 +2656,187 @@ export function StudyAccountPage() {
{exportMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{exportMessage}</p>}
</>
)}
{accountModal === 'choose2fa' && (
<>
<h2 id="account-modal-heading">Choose Your 2FA Method</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.5rem', color: '#b9b09b' }}>
Pick how you'd like to verify your identity each time you sign in.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<button type="button" style={{ textAlign: 'left', background: '#1a1a12', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '1rem 1.25rem', cursor: 'pointer', color: '#f0e9cc' }} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
const data = await readJson<{ qrDataUrl: string; secret: string }>('/api/study-auth/totp-setup-init', { method: 'POST' })
if (!data.qrDataUrl || !data.secret) throw new Error('Setup response missing QR data.')
setTwoFaQr(data.qrDataUrl)
setTwoFaSecret(data.secret)
setTwoFaCode('')
setTwoFaMessage('')
setAccountModal('setup2fa')
} catch (err) { setTwoFaMessage(err instanceof Error ? err.message : 'Could not start setup. Please try again.') }
finally { setTwoFaBusy(false) }
}}>
<p style={{ fontWeight: 700, margin: '0 0 0.25rem' }}>📱 Authenticator App</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#8a7f5a' }}>Use Google Authenticator, Authy, 1Password, or any TOTP app. Works offline. More secure.</p>
</button>
<button type="button" style={{ textAlign: 'left', background: '#1a1a12', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '1rem 1.25rem', cursor: 'pointer', color: '#f0e9cc' }} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
await readJson('/api/study-auth/2fa-setup-email', { method: 'POST' })
setTwoFaCode(''); setTwoFaMessage('')
setAccountModal('setup2fa-email')
} catch { setTwoFaMessage('Could not send code. Please try again.') }
finally { setTwoFaBusy(false) }
}}>
<p style={{ fontWeight: 700, margin: '0 0 0.25rem' }}>📧 Email Code</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#8a7f5a' }}>A one-time code sent to your email each time you sign in. Easier to set up.</p>
</button>
</div>
{twoFaMessage && <p style={{ color: '#e07a7a', marginTop: '0.75rem' }}>{twoFaMessage}</p>}
</>
)}
{accountModal === 'setup2fa-email' && (
<>
<h2 id="account-modal-heading">Verify Your Email for 2FA</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
We sent a 6-digit code to your email. Enter it below to activate email-based two-factor authentication.
</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Verification Code</label>
<input
type="text" inputMode="numeric" autoComplete="one-time-code"
value={twoFaCode}
onChange={e => setTwoFaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem', letterSpacing: '0.3em', fontSize: '1.3rem', textAlign: 'center' }}
/>
{twoFaMessage && <p style={{ color: '#e07a7a', marginBottom: '0.5rem' }}>{twoFaMessage}</p>}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn-primary" disabled={twoFaBusy || twoFaCode.length < 6} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
await readJson('/api/study-auth/2fa-setup-email-confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: twoFaCode }) })
setAuth(prev => ({ ...prev, totpEnabled: true, twoFaMethod: 'email', totpRecoveryCodesRemaining: 0 }))
closeAccountModal()
} catch (err) { setTwoFaMessage(err instanceof Error ? err.message : 'Invalid code.') }
finally { setTwoFaBusy(false) }
}}>{twoFaBusy ? 'Activating…' : 'Activate Email 2FA'}</button>
<button type="button" style={{ background: 'none', border: 'none', color: '#c9a84c', cursor: 'pointer', fontSize: '0.85rem', textDecoration: 'underline' }} onClick={async () => {
try { await readJson('/api/study-auth/2fa-setup-email', { method: 'POST' }) } catch { /* ignore */ }
}}>Resend code</button>
</div>
</>
)}
{accountModal === 'setup2fa' && (
<>
<h2 id="account-modal-heading">Enable Two-Factor Authentication</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
Scan the QR code below with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code to confirm.
</p>
{twoFaQr ? (
<div style={{ textAlign: 'center', marginBottom: '1.25rem' }}>
<img
src={twoFaQr}
alt="2FA QR code"
style={{ display: 'block', margin: '0 auto 0.75rem', borderRadius: '8px', background: '#fff', padding: '0.5rem' }}
width={180} height={180}
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
<p style={{ fontSize: '0.8rem', color: '#5a5440', margin: 0 }}>Can't scan? Enter this key manually:</p>
</div>
) : (
<p style={{ fontSize: '0.85rem', color: '#8a7f5a', marginBottom: '0.5rem' }}>Enter this key manually in your authenticator app:</p>
)}
{twoFaSecret && (
<div style={{ background: '#0a0a08', border: '1px solid rgba(201,168,76,0.2)', borderRadius: '8px', padding: '0.75rem 1rem', marginBottom: '1.25rem', textAlign: 'center', wordBreak: 'break-all' }}>
<code style={{ color: '#c9a84c', fontSize: '0.95rem', letterSpacing: '0.1em' }}>{twoFaSecret}</code>
</div>
)}
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Verification Code</label>
<input
type="text" inputMode="numeric" autoComplete="one-time-code"
value={twoFaCode}
onChange={e => setTwoFaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem', letterSpacing: '0.3em', fontSize: '1.3rem', textAlign: 'center' }}
/>
{twoFaMessage && <p style={{ color: '#e07a7a', marginBottom: '0.5rem' }}>{twoFaMessage}</p>}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" disabled={twoFaBusy || twoFaCode.length < 6} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
const data = await readJson<{ ok: boolean; recoveryCodes: string[] }>('/api/study-auth/totp-setup-confirm', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: twoFaCode }),
})
setTwoFaRecoveryCodes(data.recoveryCodes)
setAuth(prev => ({ ...prev, totpEnabled: true, totpRecoveryCodesRemaining: data.recoveryCodes.length }))
setAccountModal('recoveryCodes')
} catch (err) {
setTwoFaMessage(err instanceof Error ? err.message : 'Code incorrect. Try again.')
} finally { setTwoFaBusy(false) }
}}>{twoFaBusy ? 'Verifying' : 'Activate 2FA'}</button>
</div>
</>
)}
{accountModal === 'recoveryCodes' && (
<>
<h2 id="account-modal-heading">Save Your Recovery Codes</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
Store these codes somewhere safe. Each one can be used once if you ever lose access to your authenticator app. <strong style={{ color: '#f0ead8' }}>You won't be able to see these again.</strong>
</p>
<div style={{ background: '#0a0a08', border: '1px solid rgba(201,168,76,0.2)', borderRadius: '8px', padding: '1rem', marginBottom: '1.25rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
{twoFaRecoveryCodes.map(code => (
<code key={code} style={{ color: '#c9a84c', fontSize: '0.9rem', letterSpacing: '0.1em' }}>{code}</code>
))}
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" onClick={() => {
navigator.clipboard?.writeText(twoFaRecoveryCodes.join('\n')).catch(() => {})
}}>Copy All</button>
<button type="button" className="btn-secondary" onClick={closeAccountModal}>Done</button>
</div>
</>
)}
{accountModal === 'disable2fa' && (
<>
<h2 id="account-modal-heading">Disable Two-Factor Authentication</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
Enter your password to confirm. This will remove {auth.twoFaMethod === 'email' ? 'email code' : 'authenticator app'} 2FA from your account.
</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current Password</label>
<input
type="password" autoComplete="current-password"
value={disablePassword}
onChange={e => setDisablePassword(e.target.value)}
placeholder="Your password"
style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }}
/>
{twoFaMessage && <p style={{ color: '#e07a7a', marginBottom: '0.5rem' }}>{twoFaMessage}</p>}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-admin-remove" disabled={twoFaBusy || !disablePassword} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
await readJson('/api/study-auth/totp-disable', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: disablePassword }),
})
setAuth(prev => ({ ...prev, totpEnabled: false, totpRecoveryCodesRemaining: 0 }))
setDisablePassword('')
closeAccountModal()
} catch (err) {
setTwoFaMessage(err instanceof Error ? err.message : 'Could not disable 2FA.')
} finally { setTwoFaBusy(false) }
}}>{twoFaBusy ? 'Disabling…' : 'Disable 2FA'}</button>
<button type="button" className="btn-secondary" onClick={closeAccountModal}>Cancel</button>
</div>
</>
)}
{accountModal === 'changePassword' && (
<>
<h2 id="account-modal-heading">Change Password</h2>
+7
View File
@@ -248,6 +248,10 @@ export interface SiteContent {
emailChangeSubject: string
emailChangeBody: string
emailChangeCtaLabel: string
// ── 2FA Email OTP ──
twoFaOtpEmailSubject: string
twoFaOtpEmailBody: string
twoFaOtpEmailExpiry: string
// ── Header ──
headerFollowLabel: string
// ── Cookie banner ──
@@ -432,6 +436,9 @@ export const DEFAULTS: SiteContent = {
emailChangeSubject: 'Confirm your new email address',
emailChangeBody: 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.',
emailChangeCtaLabel: 'Confirm Email Change',
twoFaOtpEmailSubject: 'Your sign-in code — Verse by Verse with Nate',
twoFaOtpEmailBody: 'Your two-factor sign-in code is below. Enter it to complete sign-in.',
twoFaOtpEmailExpiry: 'This code expires in 10 minutes. If you did not request this, you can ignore this message.',
headerFollowLabel: 'Follow on Spotify',
cookieBannerText: 'We use optional analytics cookies to measure visits and location trends for site improvement.',
customLinks: [],