From 770d2357acfa74e6838ace3a3d256f860552ad53 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Sun, 12 Apr 2026 13:34:11 -0400 Subject: [PATCH] Add simple auth for admin access --- README.md | 5 +++ server.js | 98 ++++++++++++++++++++++++++++++++++++++++++----- src/AdminPage.tsx | 10 ++++- src/App.css | 92 ++++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 94 ++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 287 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index db51d0f..8f5a3ec 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ docker compose pull docker compose up -d ``` +Admin auth: + +- Set `ADMIN_PASSWORD` on the server/container to protect `/admin` and admin stats/maintenance endpoints. +- Without `ADMIN_PASSWORD`, admin login is disabled until configured. + The app will be available at `http://localhost:4173`. Persistent admin saves: diff --git a/server.js b/server.js index b388b79..c7aa086 100644 --- a/server.js +++ b/server.js @@ -46,10 +46,13 @@ let hitStatsWritePromise = Promise.resolve() const VISITOR_COOKIE = 'vbn_vid' const CONSENT_COOKIE = 'vbn_analytics_consent' +const ADMIN_SESSION_COOKIE = 'vbn_admin_session' const MAX_RECENT_VISITS = 1000 const VISITOR_RETENTION_DAYS_DEFAULT = 180 const BACKUP_RETENTION_DAYS = 30 const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 +const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'change-me-admin-password' const EMPTY_VISITOR_STATS = { totalVisits: 0, @@ -71,6 +74,47 @@ let contactSubmissionsWritePromise = Promise.resolve() let lastVisitorStatsWrite = { ok: true, at: null, error: null } let lastHitStatsWrite = { ok: true, at: null, error: null } let lastBackupStatus = { ok: true, at: null, error: null, file: null } +const adminSessions = new Map() + +function sha256(value) { + return createHash('sha256').update(value).digest('hex') +} + +function isAdminPasswordConfigured() { + return ADMIN_PASSWORD !== 'change-me-admin-password' +} + +function isValidAdminSession(req) { + const cookies = parseCookies(req.headers.cookie) + const sessionToken = cookies[ADMIN_SESSION_COOKIE] + if (!sessionToken) return false + + const expiresAt = adminSessions.get(sessionToken) + if (!expiresAt) return false + if (expiresAt <= Date.now()) { + adminSessions.delete(sessionToken) + return false + } + + adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS) + return true +} + +function setAdminSessionCookie(res, token) { + res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax`) +} + +function clearAdminSessionCookie(res) { + res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`) +} + +function requireAdminAuth(req, res, next) { + if (!isValidAdminSession(req)) { + res.status(401).json({ message: 'Unauthorized' }) + return + } + next() +} function normalizeIp(rawIp) { if (!rawIp) return 'unknown' @@ -686,7 +730,43 @@ app.get('/api/admin-content', async (_req, res) => { } }) -app.put('/api/admin-content', async (req, res) => { +app.get('/api/admin-auth/status', (req, res) => { + res.json({ + authenticated: isValidAdminSession(req), + configured: isAdminPasswordConfigured(), + }) +}) + +app.post('/api/admin-auth/login', (req, res) => { + const password = typeof req.body?.password === 'string' ? req.body.password : '' + + if (!isAdminPasswordConfigured()) { + res.status(503).json({ message: 'ADMIN_PASSWORD is not configured on the server.' }) + return + } + + if (sha256(password) !== sha256(ADMIN_PASSWORD)) { + res.status(401).json({ message: 'Invalid password.' }) + return + } + + const sessionToken = randomUUID() + adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS) + setAdminSessionCookie(res, sessionToken) + res.json({ ok: true }) +}) + +app.post('/api/admin-auth/logout', (req, res) => { + const cookies = parseCookies(req.headers.cookie) + const sessionToken = cookies[ADMIN_SESSION_COOKIE] + if (sessionToken) { + adminSessions.delete(sessionToken) + } + clearAdminSessionCookie(res) + res.json({ ok: true }) +}) + +app.put('/api/admin-content', requireAdminAuth, async (req, res) => { try { const { siteContent } = req.body ?? {} @@ -714,7 +794,7 @@ app.post('/api/analytics-consent', (req, res) => { res.json({ ok: true, consent }) }) -app.get('/api/admin-stats', (_req, res) => { +app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { const topPaths = Object.entries(hitStats.byPath) .sort((a, b) => b[1] - a[1]) .slice(0, 10) @@ -757,7 +837,7 @@ app.get('/api/admin-stats', (_req, res) => { }) }) -app.get('/api/admin-stats/export', async (_req, res) => { +app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => { let adminContent = null try { const raw = await readFile(DATA_FILE, 'utf8') @@ -775,7 +855,7 @@ app.get('/api/admin-stats/export', async (_req, res) => { }) }) -app.post('/api/admin-stats/clear', (_req, res) => { +app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => { hitStats = { ...EMPTY_HIT_STATS } visitorStats = { ...EMPTY_VISITOR_STATS } queueHitStatsWrite() @@ -784,18 +864,18 @@ app.post('/api/admin-stats/clear', (_req, res) => { res.json({ ok: true }) }) -app.post('/api/admin-stats/prune', (req, res) => { +app.post('/api/admin-stats/prune', requireAdminAuth, (req, res) => { const result = pruneStatsByDays(req.body?.days) createBackupSnapshot('post-prune').catch(() => {}) res.json({ ok: true, ...result }) }) -app.post('/api/admin-stats/backup', async (_req, res) => { +app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => { await createBackupSnapshot('manual') res.json({ ok: true, backup: lastBackupStatus }) }) -app.get('/api/admin-stats/backups', async (_req, res) => { +app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => { try { const backups = await listBackupPreviews() res.json({ backups }) @@ -804,7 +884,7 @@ app.get('/api/admin-stats/backups', async (_req, res) => { } }) -app.post('/api/admin-stats/backup-preview', async (req, res) => { +app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => { try { const { filename } = req.body ?? {} const preview = await readBackupPreview(filename) @@ -814,7 +894,7 @@ app.post('/api/admin-stats/backup-preview', async (req, res) => { } }) -app.post('/api/admin-stats/restore', async (req, res) => { +app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => { try { const { filename } = req.body ?? {} await restoreFromBackup(filename) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 665f934..b8d2860 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -6,6 +6,7 @@ import { DEFAULTS } from './App' interface Props { content: SiteContent onSave: (c: SiteContent) => void + onLogout: () => void | Promise } 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(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) { ✦   ✦   ✦

Site Admin

Verse by Verse with Nate

- ← Back to site +
+ ← Back to site + +
diff --git a/src/App.css b/src/App.css index f818245..9645216 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 7aa42cd..6ee91d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 + } + + return ( +
+
+

Admin Access

+

{status === 'misconfigured' ? 'Admin Not Configured' : 'Sign in to Admin'}

+ {status === 'checking' &&

Checking session...

} + {status === 'misconfigured' && ( +

Set the ADMIN_PASSWORD environment variable on the server to enable admin login.

+ )} + {status === 'unauthenticated' && ( +
+ + {errorMsg &&

{errorMsg}

} + + Back to Site +
+ )} +
+
+ ) +} + export default function App() { const [content, setContent] = useState(DEFAULTS) @@ -618,7 +710,7 @@ export default function App() { } /> } /> - } /> + } />