Beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening
This commit is contained in:
+280
-60
@@ -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&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 />} />
|
||||
|
||||
Reference in New Issue
Block a user