Add simple auth for admin access

This commit is contained in:
nmemmert
2026-04-12 13:34:11 -04:00
parent f07d8fecfb
commit 770d2357ac
5 changed files with 287 additions and 12 deletions
+8 -2
View File
@@ -6,6 +6,7 @@ import { DEFAULTS } from './App'
interface Props {
content: SiteContent
onSave: (c: SiteContent) => void
onLogout: () => void | Promise<void>
}
interface BackupPreview {
@@ -87,7 +88,7 @@ const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true },
]
export default function AdminPage({ content, onSave }: Props) {
export default function AdminPage({ content, onSave, onLogout }: Props) {
const [form, setForm] = useState<SiteContent>(content)
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
@@ -361,7 +362,12 @@ export default function AdminPage({ content, onSave }: Props) {
<span className="admin-ornament"> &nbsp; &nbsp; </span>
<h1>Site Admin</h1>
<p className="admin-sub">Verse by Verse with Nate</p>
<Link to="/" className="admin-back"> Back to site</Link>
<div className="admin-header-actions">
<Link to="/" className="admin-back"> Back to site</Link>
<button type="button" className="btn-admin-logout" onClick={() => void onLogout()}>
Log Out
</button>
</div>
</div>
<div className="admin-form-wrap">
+92
View File
@@ -916,6 +916,14 @@
text-align: center;
}
.admin-header-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.admin-ornament {
display: block;
color: #c8860a;
@@ -964,6 +972,90 @@
border-color: rgba(200, 134, 10, 0.5);
}
.btn-admin-logout {
background: transparent;
color: #a89060;
border: 1px solid rgba(168, 144, 96, 0.3);
border-radius: 999px;
padding: 0.4rem 1rem;
font-family: 'Barlow Condensed', sans-serif;
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.btn-admin-logout:hover {
color: #c8860a;
border-color: rgba(200, 134, 10, 0.5);
}
.admin-auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0a0a0a;
padding: 2rem;
}
.admin-auth-card {
width: min(520px, 100%);
background: #101010;
border: 1px solid rgba(200, 134, 10, 0.22);
border-radius: 16px;
padding: 2rem;
text-align: center;
}
.admin-auth-card h1 {
margin: 0 0 1rem;
font-family: 'Playfair Display', Georgia, serif;
color: #f0e6d0;
}
.admin-auth-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-auth-form label {
display: flex;
flex-direction: column;
gap: 0.4rem;
text-align: left;
font-family: 'Barlow Condensed', sans-serif;
color: #c8860a;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.admin-auth-form input {
background: #111;
border: 1px solid rgba(200, 134, 10, 0.22);
border-radius: 8px;
color: #f0e6d0;
padding: 0.7rem 0.85rem;
font-family: 'Barlow Condensed', sans-serif;
font-size: 1rem;
}
.admin-auth-error,
.admin-auth-note {
margin: 0;
font-family: 'Barlow Condensed', sans-serif;
}
.admin-auth-error {
color: #e05c5c;
}
.admin-auth-note {
color: #a89060;
}
.admin-form-wrap {
max-width: 760px;
margin: 0 auto;
+93 -1
View File
@@ -600,6 +600,98 @@ function LegalPage({ title, body }: { title: string; body: string[] }) {
)
}
function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) {
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
const [password, setPassword] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
fetch('/api/admin-auth/status')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Status failed'))))
.then(data => {
const next = data as { authenticated?: boolean; configured?: boolean }
if (next.configured === false) {
setStatus('misconfigured')
return
}
setStatus(next.authenticated ? 'authenticated' : 'unauthenticated')
})
.catch(() => {
setStatus('unauthenticated')
})
}, [])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setErrorMsg('')
try {
const res = await fetch('/api/admin-auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Login failed.')
setSubmitting(false)
return
}
setStatus('authenticated')
setPassword('')
} catch {
setErrorMsg('Login failed.')
} finally {
setSubmitting(false)
}
}
async function handleLogout() {
try {
await fetch('/api/admin-auth/logout', { method: 'POST' })
} finally {
setStatus('unauthenticated')
}
}
if (status === 'authenticated') {
return <AdminPage content={content} onSave={onSave} onLogout={handleLogout} />
}
return (
<main className="admin-auth-page" aria-label="Admin sign in">
<div className="admin-auth-card">
<p className="eyebrow">Admin Access</p>
<h1>{status === 'misconfigured' ? 'Admin Not Configured' : 'Sign in to Admin'}</h1>
{status === 'checking' && <p className="admin-auth-note">Checking session...</p>}
{status === 'misconfigured' && (
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable on the server to enable admin login.</p>
)}
{status === 'unauthenticated' && (
<form className="admin-auth-form" onSubmit={handleLogin}>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Signing In…' : 'Sign In'}
</button>
<Link to="/" className="btn-secondary">Back to Site</Link>
</form>
)}
</div>
</main>
)
}
export default function App() {
const [content, setContent] = useState<SiteContent>(DEFAULTS)
@@ -618,7 +710,7 @@ export default function App() {
<Routes>
<Route path="/" element={<LandingPage content={content} />} />
<Route path="/thanks" element={<ThankYouPage />} />
<Route path="/admin" element={<AdminPage content={content} onSave={setContent} />} />
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
<Route
path="/privacy"
element={(