Add simple auth for admin access
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+8
-2
@@ -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">✦ ✦ ✦</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
@@ -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
@@ -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={(
|
||||
|
||||
Reference in New Issue
Block a user