Beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening

This commit is contained in:
nmemmert
2026-05-04 13:56:04 -04:00
parent 7f56060d6b
commit 1551599305
16 changed files with 1597 additions and 682 deletions
+280 -60
View File
@@ -18,13 +18,14 @@ const AMAZON_MUSIC_URL = '/amazon'
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function StudyDownloadForm() {
function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: string }) {
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('')
const [downloadUrl, setDownloadUrl] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
@@ -35,6 +36,7 @@ function StudyDownloadForm() {
setStatus('submitting')
setErrorMsg('')
setSuccessMsg('')
setDownloadUrl('')
try {
const res = await fetch('/api/study-downloads/titus', {
@@ -52,6 +54,7 @@ function StudyDownloadForm() {
setStatus('success')
setSuccessMsg('Your download should start now. If not, use the link below.')
setDownloadUrl(data.downloadUrl)
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
@@ -94,8 +97,13 @@ function StudyDownloadForm() {
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
{status === 'success' && <p className="study-download-success">{successMsg}</p>}
{status === 'success' && downloadUrl && (
<p className="study-download-success">
<a href={downloadUrl}>Click here if your download does not start automatically.</a>
</p>
)}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Preparing Download...' : 'Download Titus Study'}
{status === 'submitting' ? 'Preparing Download...' : buttonText}
</button>
</form>
)
@@ -108,6 +116,7 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [successMsg, setSuccessMsg] = useState('')
const [downloadUrl, setDownloadUrl] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
@@ -118,6 +127,7 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
setStatus('submitting')
setErrorMsg('')
setSuccessMsg('')
setDownloadUrl('')
try {
const res = await fetch('/api/resource-download', {
@@ -135,6 +145,7 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
setStatus('success')
setSuccessMsg('Your download should start now. If not, use the link below.')
setDownloadUrl(data.downloadUrl)
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
@@ -177,6 +188,11 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
{status === 'success' && <p className="study-download-success">{successMsg}</p>}
{status === 'success' && downloadUrl && (
<p className="study-download-success">
<a href={downloadUrl}>Click here if your download does not start automatically.</a>
</p>
)}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Preparing Download...' : buttonText}
</button>
@@ -184,6 +200,71 @@ function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string;
)
}
function buildCustomResourceDownloadId(id: string) {
return `custom:${id}`
}
function buildArchivedResourceDownloadId(seriesId: string, linkId: string) {
return `archived:${seriesId}:${linkId}`
}
function buildCustomDownloadPageId(id: string) {
return `custom--${id}`
}
function buildArchivedDownloadPageId(seriesId: string, linkId: string) {
return `archived--${seriesId}--${linkId}`
}
interface DownloadPageResource {
id: string
label: string
imageUrl?: string
summary: string
tags: string[]
buttonText: string
resourceId: string
}
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
if (!pageId) return null
if (pageId.startsWith('custom--')) {
const customId = pageId.slice('custom--'.length)
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
if (!resource) return null
return {
id: pageId,
label: resource.label,
imageUrl: resource.imageUrl,
summary: resource.description || 'Complete the short form below and your download will start right away.',
tags: resource.tags ?? [],
buttonText: `Download ${resource.label}`,
resourceId: buildCustomResourceDownloadId(resource.id),
}
}
if (pageId.startsWith('archived--')) {
const [, seriesId, linkId] = pageId.split('--')
const series = (content.archivedSeries ?? []).find(item => item.id === seriesId)
const link = (series?.resourceLinks ?? []).find(item => item.id === linkId)
if (!series || !link) return null
return {
id: pageId,
label: link.label || series.title || 'Download Resource',
imageUrl: series.imageUrl,
summary: link.description || series.description || 'Fill out the form below to access this download from a previous study.',
tags: [],
buttonText: `Download ${link.label || series.title || 'Resource'}`,
resourceId: buildArchivedResourceDownloadId(series.id, link.id),
}
}
return null
}
function AnalyticsConsentBanner() {
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
const saved = localStorage.getItem(CONSENT_KEY)
@@ -341,7 +422,7 @@ function SiteHeader() {
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
<NavLink to="/" end className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Home</NavLink>
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink>
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink>
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Downloads</NavLink>
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
<a
@@ -473,10 +554,13 @@ function StudyGuideSection({ content }: { content: SiteContent }) {
/>
</div>
<div className="guide-text">
<p className="eyebrow">Downloads</p>
<h1 className="guide-page-title">Study Guides and Downloads</h1>
<p className="guide-page-intro">Start with the primary guide below, then explore the rest of the download library further down the page.</p>
<p className="eyebrow">Free Download</p>
<h2>{content.studyGuideTitle}</h2>
<p>{content.studyGuideDescription}</p>
<StudyDownloadForm />
<StudyDownloadForm buttonText={`Download ${content.studyGuideTitle || 'Guide'}`} />
{content.studyGuideUrl && (
<div className="guide-actions">
<a
@@ -590,40 +674,132 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
)
}
function CustomResourcesSection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(l => l.placement === 'resources')
if (resources.length === 0) return null
function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
if (resources.length === 0 && archivedWithResources.length === 0) return null
return (
<section className="section-resources" aria-label="More resources">
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> More Resources{' '}
<span className="ornament"></span>
</h2>
<div className="resources-list">
{resources.map(resource => (
<article key={resource.id} className="resource-download-card">
<div className="resource-download-header">
{resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span>
{(resource.tags ?? []).length > 0 && (
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
)}
<div className="download-library-head">
<p className="eyebrow">Download Library</p>
<h2 className="section-heading">More guides, worksheets, and past study downloads.</h2>
<p className="download-library-copy">Keep the main guide featured at the top, and use this library for every other download you want available on the page.</p>
</div>
{resources.length > 0 && (
<div className="download-library-group">
<div className="download-library-group-head">
<h3>Current Downloads</h3>
<p>Extra files you want people to grab right now.</p>
</div>
<div className="resources-list">
{resources.map(resource => (
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span>
{resource.description && <p className="resource-link-description">{resource.description}</p>}
{(resource.tags ?? []).length > 0 && (
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
)}
<span className="resource-link-action">Open download page </span>
</div>
</div>
</Link>
))}
</div>
</div>
)}
{archivedWithResources.length > 0 && (
<div className="download-library-group">
<div className="download-library-group-head">
<h3>Previous Studies</h3>
<p>Downloads from earlier series that you still want available.</p>
</div>
{archivedWithResources.map(series => (
<div key={series.id} className="archive-series-resources">
<div className="download-library-series-head">
<h4>{series.title || 'Archived Study'}</h4>
{series.description && <p>{series.description}</p>}
</div>
<div className="resources-list">
{(series.resourceLinks ?? []).map(link => (
<Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{series.imageUrl && (
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span>
{link.description && <p className="resource-link-description">{link.description}</p>}
<span className="resource-link-action">Open download page </span>
</div>
</div>
</Link>
))}
</div>
</div>
<ResourceDownloadForm resourceId={resource.id} buttonText={`Download ${resource.label}`} />
</article>
))}
</div>
))}
</div>
)}
</div>
</section>
)
}
function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id)
if (!resource) {
return (
<main className="thanks-page" aria-label="Download not found">
<div className="thanks-card">
<p className="eyebrow">Downloads</p>
<h1>Download not found</h1>
<p>The download you requested is not available right now.</p>
<Link to="/resources" className="btn-primary">Back to Downloads</Link>
</div>
</main>
)
}
return (
<main className="thanks-page download-detail-page" aria-label={resource.label}>
<div className="thanks-card download-detail-card">
<p className="eyebrow">Downloads</p>
<h1>{resource.label}</h1>
<p>{resource.summary}</p>
{resource.imageUrl && (
<div className="download-detail-art">
<img src={resource.imageUrl} alt={resource.label} className="guide-cover-img" />
</div>
)}
{resource.tags.length > 0 && (
<div className="resource-link-tags">{resource.tags.join(', ')}</div>
)}
<div className="download-detail-form-wrap">
<ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />
</div>
<div className="download-detail-actions">
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
</div>
</div>
</main>
)
}
function CustomBlocksSection({ content }: { content: SiteContent }) {
return (
<>
@@ -703,7 +879,7 @@ function LandingPage({ content }: { content: SiteContent }) {
<SpotifyIcon />
Listen to Series
</a>
<Link to="/resources" className="btn-secondary">View Study Resources</Link>
<Link to="/resources" className="btn-secondary">View Downloads</Link>
</div>
</article>
</div>
@@ -722,8 +898,8 @@ function LandingPage({ content }: { content: SiteContent }) {
<p>Listen to latest episodes and platform links.</p>
</Link>
<Link to="/resources" className="home-jump-card">
<h3>Resources</h3>
<p>Study guide, links, and archived study resources.</p>
<h3>Downloads</h3>
<p>Main guide, extra downloads, and past study files.</p>
</Link>
<Link to="/questions" className="home-jump-card">
<h3>Q&amp;A</h3>
@@ -851,36 +1027,11 @@ function EpisodesPage({ content }: { content: SiteContent }) {
}
function ResourcesPage({ content }: { content: SiteContent }) {
const archivedSeries = content.archivedSeries ?? []
return (
<div className="site">
<SiteHeader />
<StudyGuideSection content={content} />
<CustomResourcesSection content={content} />
{archivedSeries.length > 0 && (
<section className="section-resources" aria-label="Archived studies">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> Archived Studies{' '}
<span className="ornament">✦</span>
</h2>
<div className="resources-list">
{archivedSeries.map(series => (
<a
key={series.id}
href={series.listenUrl || SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="resource-link"
>
{series.title} {series.description ? `— ${series.description}` : ''}
</a>
))}
</div>
</div>
</section>
)}
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
@@ -1125,6 +1276,10 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
const [password, setPassword] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [submitting, setSubmitting] = useState(false)
// TOTP two-step state
const [totpRequired, setTotpRequired] = useState(false)
const [pendingToken, setPendingToken] = useState('')
const [totpCode, setTotpCode] = useState('')
useEffect(() => {
fetch('/api/admin-auth/status')
@@ -1152,9 +1307,16 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Login failed.')
setErrorMsg(data.message ?? 'Login failed.')
setSubmitting(false)
return
}
if (data.totpRequired && data.pendingToken) {
setPendingToken(data.pendingToken)
setTotpRequired(true)
setPassword('')
setSubmitting(false)
return
}
@@ -1167,11 +1329,45 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
}
}
async function handleTotpVerify(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setErrorMsg('')
try {
const res = await fetch('/api/admin-auth/totp-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pendingToken, code: totpCode }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; usedRecoveryCode?: boolean; remainingRecoveryCodes?: number; message?: string }
if (!res.ok) {
setErrorMsg(data.message ?? 'Invalid code.')
setSubmitting(false)
return
}
setStatus('authenticated')
setTotpCode('')
} catch {
setErrorMsg('Verification failed.')
} finally {
setSubmitting(false)
}
}
function handleBackToPassword() {
setTotpRequired(false)
setPendingToken('')
setTotpCode('')
setErrorMsg('')
}
async function handleLogout() {
try {
await fetch('/api/admin-auth/logout', { method: 'POST' })
} finally {
setStatus('unauthenticated')
setTotpRequired(false)
setPendingToken('')
}
}
@@ -1188,7 +1384,7 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
{status === 'misconfigured' && (
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable on the server to enable admin login.</p>
)}
{status === 'unauthenticated' && (
{status === 'unauthenticated' && !totpRequired && (
<form className="admin-auth-form" onSubmit={handleLogin}>
<label>
Password
@@ -1207,6 +1403,29 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
<Link to="/" className="btn-secondary">Back to Site</Link>
</form>
)}
{status === 'unauthenticated' && totpRequired && (
<form className="admin-auth-form" onSubmit={handleTotpVerify}>
<p className="admin-auth-note">Enter the 6-digit code from your authenticator app, or one of your recovery codes.</p>
<label>
Code
<input
type="text"
inputMode="numeric"
value={totpCode}
onChange={e => setTotpCode(e.target.value)}
autoComplete="one-time-code"
placeholder="000000 or XXXX-XXXX-XXXX"
autoFocus
required
/>
</label>
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Verifying' : 'Verify'}
</button>
<button type="button" className="btn-secondary" onClick={handleBackToPassword}>Back</button>
</form>
)}
</div>
</main>
)
@@ -1350,6 +1569,7 @@ export default function App() {
<Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
<Route path="/resources" element={<ResourcesPage content={content} />} />
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
<Route path="/about" element={<AboutPage content={content} />} />
<Route path="/contact" element={<ContactPage content={content} />} />
<Route path="/questions" element={<QuestionsPage />} />