browser-based admin setup on first visit, no CLI needed

This commit is contained in:
2026-03-21 19:34:51 +02:00
parent 010c811a48
commit 1566f85cc9
3 changed files with 142 additions and 23 deletions

View File

@@ -1,24 +1,34 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useBranding } from '../../hooks/useBranding'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useToast } from '../../hooks/useToast'
import { api } from '../../lib/api'
export default function AdminLogin() {
useDocumentTitle('Admin Login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null)
const nav = useNavigate()
const { appName } = useBranding()
const toast = useToast()
const submit = async (e: React.FormEvent) => {
useDocumentTitle(needsSetup ? 'Setup' : 'Admin Login')
useEffect(() => {
api.get<{ needsSetup: boolean }>('/admin/setup-status')
.then((r) => setNeedsSetup(r.needsSetup))
.catch(() => setNeedsSetup(false))
}, [])
const submitLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) return
setLoading(true)
setError('')
try {
await api.post('/admin/login', { email, password })
nav('/admin')
@@ -29,6 +39,32 @@ export default function AdminLogin() {
}
}
const submitSetup = async (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) return
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
setLoading(true)
setError('')
try {
await api.post('/admin/setup', { email, password })
toast.success('Admin account created')
nav('/admin')
} catch {
setError('Setup failed')
} finally {
setLoading(false)
}
}
if (needsSetup === null) return null
return (
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
<div
@@ -46,44 +82,67 @@ export default function AdminLogin() {
className="font-bold mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-xl)' }}
>
Admin Login
{needsSetup ? 'Welcome to Echoboard' : 'Admin Login'}
</h1>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{appName} administration
{needsSetup ? 'Create your admin account to get started' : `${appName} administration`}
</p>
</div>
<form onSubmit={submit} className="flex flex-col gap-3">
<form onSubmit={needsSetup ? submitSetup : submitLogin} className="flex flex-col gap-3">
<div>
<label htmlFor="admin-email" className="sr-only">Email</label>
<label htmlFor="admin-email" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Email {needsSetup && <span style={{ color: 'var(--error)' }}>*</span>}
</label>
<input
id="admin-email"
className="input"
className="input w-full"
type="email"
placeholder="Email"
placeholder="admin@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
aria-invalid={!!error}
aria-describedby={error ? 'admin-login-error' : undefined}
style={{ borderColor: error ? 'var(--error)' : undefined }}
/>
</div>
<div>
<label htmlFor="admin-password" className="sr-only">Password</label>
<label htmlFor="admin-password" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Password {needsSetup && <span style={{ color: 'var(--error)' }}>*</span>}
</label>
<input
id="admin-password"
className="input"
className="input w-full"
type="password"
placeholder="Password"
placeholder={needsSetup ? 'At least 8 characters' : 'Password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
autoComplete={needsSetup ? 'new-password' : 'current-password'}
required
aria-invalid={!!error}
aria-describedby={error ? 'admin-login-error' : undefined}
style={{ borderColor: error ? 'var(--error)' : undefined }}
/>
</div>
{needsSetup && (
<div>
<label htmlFor="admin-confirm" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Confirm password <span style={{ color: 'var(--error)' }}>*</span>
</label>
<input
id="admin-confirm"
className="input w-full"
type="password"
placeholder="Type your password again"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
aria-invalid={!!error}
aria-describedby={error ? 'admin-login-error' : undefined}
/>
</div>
)}
{error && (
<p id="admin-login-error" role="alert" className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
@@ -99,7 +158,10 @@ export default function AdminLogin() {
opacity: loading ? 0.6 : 1,
}}
>
{loading ? 'Signing in...' : 'Sign in'}
{loading
? (needsSetup ? 'Creating account...' : 'Signing in...')
: (needsSetup ? 'Create admin account' : 'Sign in')
}
</button>
</form>
</div>