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

@@ -30,13 +30,9 @@ Then start it:
docker compose up -d docker compose up -d
``` ```
Create the initial admin account: Visit `http://localhost:3000/admin` (or whatever port you set) and create your admin account right in the browser. This setup screen only appears once - before any admin exists.
```bash Put a reverse proxy in front for HTTPS (required for passkeys to work).
docker compose exec app npx tsx packages/api/src/cli/create-admin.ts
```
The app is at `http://localhost:3000` (or whatever port you set). Put a reverse proxy in front for HTTPS.
## What's included ## What's included

View File

@@ -40,7 +40,68 @@ async function ensureLinkedUser(adminId: string): Promise<string> {
}, { isolationLevel: "Serializable" }); }, { isolationLevel: "Serializable" });
} }
const setupBody = z.object({
email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters"),
});
export default async function adminAuthRoutes(app: FastifyInstance) { export default async function adminAuthRoutes(app: FastifyInstance) {
// check if initial setup is needed (no admin exists)
app.get(
"/admin/setup-status",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (_req, reply) => {
const count = await prisma.adminUser.count();
reply.send({ needsSetup: count === 0 });
}
);
// initial admin setup (only works when no admin exists)
app.post<{ Body: z.infer<typeof setupBody> }>(
"/admin/setup",
{ config: { rateLimit: { max: 3, timeWindow: "15 minutes" } } },
async (req, reply) => {
const count = await prisma.adminUser.count();
if (count > 0) {
reply.status(403).send({ error: "Setup already completed" });
return;
}
const body = setupBody.parse(req.body);
const hash = await bcrypt.hash(body.password, 12);
const admin = await prisma.adminUser.create({
data: { email: body.email, passwordHash: hash, role: "SUPER_ADMIN" },
});
const linkedUserId = await ensureLinkedUser(admin.id);
const adminToken = jwt.sign(
{ sub: admin.id, type: "admin", aud: "echoboard:admin", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "4h" }
);
const userToken = jwt.sign(
{ sub: linkedUserId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
config.JWT_SECRET,
{ expiresIn: "4h" }
);
reply
.setCookie("echoboard_admin", adminToken, {
path: "/", httpOnly: true, sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 4,
})
.setCookie("echoboard_passkey", userToken, {
path: "/", httpOnly: true, sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 4,
})
.send({ ok: true });
}
);
app.post<{ Body: z.infer<typeof loginBody> }>( app.post<{ Body: z.infer<typeof loginBody> }>(
"/admin/login", "/admin/login",
{ config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } }, { config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } },

View File

@@ -1,24 +1,34 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useBranding } from '../../hooks/useBranding' import { useBranding } from '../../hooks/useBranding'
import { useDocumentTitle } from '../../hooks/useDocumentTitle' import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useToast } from '../../hooks/useToast'
import { api } from '../../lib/api' import { api } from '../../lib/api'
export default function AdminLogin() { export default function AdminLogin() {
useDocumentTitle('Admin Login')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null)
const nav = useNavigate() const nav = useNavigate()
const { appName } = useBranding() 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() e.preventDefault()
if (!email || !password) return if (!email || !password) return
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
await api.post('/admin/login', { email, password }) await api.post('/admin/login', { email, password })
nav('/admin') 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 ( return (
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}> <div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
<div <div
@@ -46,44 +82,67 @@ export default function AdminLogin() {
className="font-bold mb-1" className="font-bold mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-xl)' }} style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-xl)' }}
> >
Admin Login {needsSetup ? 'Welcome to Echoboard' : 'Admin Login'}
</h1> </h1>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}> <p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{appName} administration {needsSetup ? 'Create your admin account to get started' : `${appName} administration`}
</p> </p>
</div> </div>
<form onSubmit={submit} className="flex flex-col gap-3"> <form onSubmit={needsSetup ? submitSetup : submitLogin} className="flex flex-col gap-3">
<div> <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 <input
id="admin-email" id="admin-email"
className="input" className="input w-full"
type="email" type="email"
placeholder="Email" placeholder="admin@example.com"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
autoComplete="email" autoComplete="email"
required
aria-invalid={!!error} aria-invalid={!!error}
aria-describedby={error ? 'admin-login-error' : undefined} aria-describedby={error ? 'admin-login-error' : undefined}
style={{ borderColor: error ? 'var(--error)' : undefined }}
/> />
</div> </div>
<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 <input
id="admin-password" id="admin-password"
className="input" className="input w-full"
type="password" type="password"
placeholder="Password" placeholder={needsSetup ? 'At least 8 characters' : 'Password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" autoComplete={needsSetup ? 'new-password' : 'current-password'}
required
aria-invalid={!!error} aria-invalid={!!error}
aria-describedby={error ? 'admin-login-error' : undefined} aria-describedby={error ? 'admin-login-error' : undefined}
style={{ borderColor: error ? 'var(--error)' : undefined }}
/> />
</div> </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 && ( {error && (
<p id="admin-login-error" role="alert" className="text-xs" style={{ color: 'var(--error)' }}>{error}</p> <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, opacity: loading ? 0.6 : 1,
}} }}
> >
{loading ? 'Signing in...' : 'Sign in'} {loading
? (needsSetup ? 'Creating account...' : 'Signing in...')
: (needsSetup ? 'Create admin account' : 'Sign in')
}
</button> </button>
</form> </form>
</div> </div>