cool stuff
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user