cool stuff
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
"byStudy": {
|
"byStudy": {
|
||||||
"colossians": {
|
"colossians": {
|
||||||
"completedSectionIds": [
|
"completedSectionIds": [
|
||||||
"1-1-2"
|
"1-1-2",
|
||||||
|
"1-3-8"
|
||||||
],
|
],
|
||||||
"quizAnswers": {
|
"quizAnswers": {
|
||||||
"1-1-2": [
|
"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"
|
"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",
|
"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,
|
"pendingEmailChange": null,
|
||||||
"updatedAt": "2026-06-03T14:16:38.510Z"
|
"updatedAt": "2026-06-03T17:47:59.104Z",
|
||||||
|
"totpSecretPending": "FGR7UI5TWA4HB4DURCXEUXATTVVVJDJ5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
|
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
|
||||||
@@ -40,5 +41,5 @@
|
|||||||
"pendingEmailChange": null
|
"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 lastCachePurgeStatus = { ok: true, at: null, error: null }
|
||||||
let lastDeployHookStatus = { ok: true, at: null, error: null }
|
let lastDeployHookStatus = { ok: true, at: null, error: null }
|
||||||
const studySessions = new Map()
|
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) {
|
function sanitizeReplyTemplates(value) {
|
||||||
if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES]
|
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) {
|
async function sendStudyAccountDeletedEmail(email, displayName) {
|
||||||
if (!process.env.RESEND_API_KEY) return
|
if (!process.env.RESEND_API_KEY) return
|
||||||
try {
|
try {
|
||||||
@@ -3004,6 +3076,9 @@ app.get('/api/study-auth/status', (req, res) => {
|
|||||||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||||||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
||||||
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||||||
|
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
|
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.lastLoginAt = new Date().toISOString()
|
||||||
user.updatedAt = user.lastLoginAt
|
user.updatedAt = user.lastLoginAt
|
||||||
queueStudyUsersWrite()
|
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) => {
|
app.get('/api/study-enrollment', requireStudyAuth, (req, res) => {
|
||||||
const user = req.studyUser
|
const user = req.studyUser
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -359,6 +359,9 @@ export function sanitizeSiteContent(siteContent) {
|
|||||||
emailChangeSubject: typeof siteContent.emailChangeSubject === 'string' ? siteContent.emailChangeSubject.trim().slice(0, 200) : '',
|
emailChangeSubject: typeof siteContent.emailChangeSubject === 'string' ? siteContent.emailChangeSubject.trim().slice(0, 200) : '',
|
||||||
emailChangeBody: typeof siteContent.emailChangeBody === 'string' ? siteContent.emailChangeBody.trim().slice(0, 500) : '',
|
emailChangeBody: typeof siteContent.emailChangeBody === 'string' ? siteContent.emailChangeBody.trim().slice(0, 500) : '',
|
||||||
emailChangeCtaLabel: typeof siteContent.emailChangeCtaLabel === 'string' ? siteContent.emailChangeCtaLabel.trim().slice(0, 80) : '',
|
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: {
|
seo: {
|
||||||
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
|
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,
|
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']}
|
fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']}
|
||||||
openKey="email-change" />
|
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()}
|
{renderSaveStatus()}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@@ -939,6 +945,9 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
|
|||||||
{ key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' },
|
{ key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' },
|
||||||
{ key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
{ key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
||||||
{ key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' },
|
{ key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||||||
|
{ key: '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[] {
|
function normalizeStudies(siteContent: SiteContent): StudyProgram[] {
|
||||||
|
|||||||
+368
-34
@@ -14,6 +14,9 @@ type StudyAuthState = {
|
|||||||
subscribeNewsletter?: boolean
|
subscribeNewsletter?: boolean
|
||||||
studyRemindersEnabled?: boolean
|
studyRemindersEnabled?: boolean
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
totpEnabled?: boolean
|
||||||
|
twoFaMethod?: 'app' | 'email' | null
|
||||||
|
totpRecoveryCodesRemaining?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudyAuthStatusResponse = {
|
type StudyAuthStatusResponse = {
|
||||||
@@ -24,6 +27,9 @@ type StudyAuthStatusResponse = {
|
|||||||
subscribeNewsletter?: boolean
|
subscribeNewsletter?: boolean
|
||||||
studyRemindersEnabled?: boolean
|
studyRemindersEnabled?: boolean
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
totpEnabled?: boolean
|
||||||
|
twoFaMethod?: 'app' | 'email' | null
|
||||||
|
totpRecoveryCodesRemaining?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudyAccountOverview = {
|
type StudyAccountOverview = {
|
||||||
@@ -891,6 +897,11 @@ export function StudySignupPage() {
|
|||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
const [loggedInAs, setLoggedInAs] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
|
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
|
||||||
@@ -911,11 +922,16 @@ export function StudySignupPage() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: email, password, ...(mode === 'signup' ? { subscribe: subscribeNewsletter } : {}) }),
|
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)
|
setDone(true)
|
||||||
setLoggedInAs(data.username ?? email.trim().toLowerCase())
|
setLoggedInAs(data.username ?? email.trim().toLowerCase())
|
||||||
navigate('/study')
|
navigate('/study')
|
||||||
@@ -926,6 +942,26 @@ export function StudySignupPage() {
|
|||||||
}
|
}
|
||||||
}, [mode, email, password, subscribeNewsletter])
|
}, [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 (
|
return (
|
||||||
<main className="thanks-page" aria-label={mode === 'signup' ? 'Create account' : 'Sign in'}>
|
<main className="thanks-page" aria-label={mode === 'signup' ? 'Create account' : 'Sign in'}>
|
||||||
<div className="thanks-card study-signup-page-card">
|
<div className="thanks-card study-signup-page-card">
|
||||||
@@ -941,6 +977,56 @@ export function StudySignupPage() {
|
|||||||
<Link to="/study" className="btn-secondary">Browse Tracks</Link>
|
<Link to="/study" className="btn-secondary">Browse Tracks</Link>
|
||||||
</div>
|
</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">
|
<div className="study-signup-form">
|
||||||
@@ -1265,7 +1351,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
const [progressSaving, setProgressSaving] = useState(false)
|
const [progressSaving, setProgressSaving] = useState(false)
|
||||||
const [progressMessage, setProgressMessage] = useState('')
|
const [progressMessage, setProgressMessage] = useState('')
|
||||||
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
|
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
|
||||||
const [checkpointReflection, setCheckpointReflection] = useState('')
|
const [_checkpointReflection, _setCheckpointReflection] = useState('')
|
||||||
|
|
||||||
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
|
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
|
||||||
const lessonAudioEmbedUrl = useMemo(() => {
|
const lessonAudioEmbedUrl = useMemo(() => {
|
||||||
@@ -1330,7 +1416,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCheckpointAnswers({})
|
setCheckpointAnswers({})
|
||||||
setCheckpointReflection('')
|
|
||||||
}, [section?.id])
|
}, [section?.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1440,15 +1525,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
async function submitCheckpoint() {
|
async function submitCheckpoint() {
|
||||||
if (!study || !section?.id || progressSaving) return
|
if (!study || !section?.id || progressSaving) return
|
||||||
const checkpointQuestions = section.checkpointQuestions ?? []
|
const checkpointQuestions = section.checkpointQuestions ?? []
|
||||||
const reflectionRequired = !(checkpointQuestions.length > 0)
|
|
||||||
const allAnswered = checkpointQuestions.every((_, index) => (checkpointAnswers[index] ?? '').trim().length > 0)
|
const allAnswered = checkpointQuestions.every((_, index) => (checkpointAnswers[index] ?? '').trim().length > 0)
|
||||||
|
|
||||||
if (reflectionRequired && !checkpointReflection.trim()) {
|
if (!allAnswered) {
|
||||||
setProgressMessage('Please write a short reflection before submitting the checkpoint.')
|
setProgressMessage('Please answer all checkpoint questions to mark this lesson complete.')
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!reflectionRequired && !allAnswered) {
|
|
||||||
setProgressMessage('Please answer all checkpoint questions before submitting.')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1459,9 +1539,9 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
||||||
setProgressMessage('Checkpoint submitted and lesson marked complete.')
|
setProgressMessage('')
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setProgressSaving(false)
|
setProgressSaving(false)
|
||||||
}
|
}
|
||||||
@@ -1616,13 +1696,51 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{(section.checkpointPrompt || (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">
|
<article className="study-class-block">
|
||||||
<h2>Checkpoint</h2>
|
<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>}
|
{section.checkpointPrompt && <p className="study-detail-copy">{section.checkpointPrompt}</p>}
|
||||||
{section.checkpointQuestions && section.checkpointQuestions.length > 0 ? (
|
|
||||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
{section.checkpointQuestions.map((question, index) => (
|
{section.checkpointQuestions!.map((question, index) => (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{question}</p>
|
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{question}</p>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1630,30 +1748,28 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
value={checkpointAnswers[index] ?? ''}
|
value={checkpointAnswers[index] ?? ''}
|
||||||
onChange={e => setCheckpointAnswers(prev => ({ ...prev, [index]: e.target.value }))}
|
onChange={e => setCheckpointAnswers(prev => ({ ...prev, [index]: e.target.value }))}
|
||||||
placeholder="Write your answer here..."
|
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>
|
</div>
|
||||||
) : (
|
<div style={{ marginTop: '1.25rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<div>
|
<button
|
||||||
<textarea
|
type="button"
|
||||||
rows={4}
|
className="btn-primary"
|
||||||
value={checkpointReflection}
|
onClick={submitCheckpoint}
|
||||||
onChange={e => setCheckpointReflection(e.target.value)}
|
disabled={!isEnrolled || progressSaving || !allAnswered}
|
||||||
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' }}
|
{progressSaving ? 'Saving…' : lessonCompleted ? 'Update Answers' : 'Mark as Completed'}
|
||||||
/>
|
|
||||||
</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>
|
</button>
|
||||||
{lessonCompleted && <span style={{ color: '#a39d8d' }}>Checkpoint completed for this lesson.</span>}
|
{!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>
|
||||||
</article>
|
</article>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
||||||
@@ -1990,7 +2106,16 @@ export function StudyAccountPage() {
|
|||||||
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
|
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
|
||||||
const [prefMessage, setPrefMessage] = useState('')
|
const [prefMessage, setPrefMessage] = useState('')
|
||||||
const [prefBusy, setPrefBusy] = useState(false)
|
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 [emailCurrentPassword, setEmailCurrentPassword] = useState('')
|
||||||
const [emailNew, setEmailNew] = useState('')
|
const [emailNew, setEmailNew] = useState('')
|
||||||
@@ -2031,6 +2156,9 @@ export function StudyAccountPage() {
|
|||||||
studyRemindersEnabled: data.studyRemindersEnabled === true,
|
studyRemindersEnabled: data.studyRemindersEnabled === true,
|
||||||
avatarUrl: data.avatarUrl ?? '',
|
avatarUrl: data.avatarUrl ?? '',
|
||||||
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
|
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 }))
|
.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-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('changeEmail')}>Change Email</button>
|
||||||
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changePassword')}>Change Password</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>
|
</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>
|
</section>
|
||||||
|
|
||||||
{accountModal !== 'none' && (
|
{accountModal !== 'none' && (
|
||||||
@@ -2503,6 +2656,187 @@ export function StudyAccountPage() {
|
|||||||
{exportMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{exportMessage}</p>}
|
{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' && (
|
{accountModal === 'changePassword' && (
|
||||||
<>
|
<>
|
||||||
<h2 id="account-modal-heading">Change Password</h2>
|
<h2 id="account-modal-heading">Change Password</h2>
|
||||||
|
|||||||
@@ -248,6 +248,10 @@ export interface SiteContent {
|
|||||||
emailChangeSubject: string
|
emailChangeSubject: string
|
||||||
emailChangeBody: string
|
emailChangeBody: string
|
||||||
emailChangeCtaLabel: string
|
emailChangeCtaLabel: string
|
||||||
|
// ── 2FA Email OTP ──
|
||||||
|
twoFaOtpEmailSubject: string
|
||||||
|
twoFaOtpEmailBody: string
|
||||||
|
twoFaOtpEmailExpiry: string
|
||||||
// ── Header ──
|
// ── Header ──
|
||||||
headerFollowLabel: string
|
headerFollowLabel: string
|
||||||
// ── Cookie banner ──
|
// ── Cookie banner ──
|
||||||
@@ -432,6 +436,9 @@ export const DEFAULTS: SiteContent = {
|
|||||||
emailChangeSubject: 'Confirm your new email address',
|
emailChangeSubject: 'Confirm your new email address',
|
||||||
emailChangeBody: 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.',
|
emailChangeBody: 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.',
|
||||||
emailChangeCtaLabel: 'Confirm Email Change',
|
emailChangeCtaLabel: 'Confirm Email Change',
|
||||||
|
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',
|
headerFollowLabel: 'Follow on Spotify',
|
||||||
cookieBannerText: 'We use optional analytics cookies to measure visits and location trends for site improvement.',
|
cookieBannerText: 'We use optional analytics cookies to measure visits and location trends for site improvement.',
|
||||||
customLinks: [],
|
customLinks: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user