From 1566f85cc934c1fd5ed8d8c22afc8bdd6ed1867b Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 21 Mar 2026 19:34:51 +0200 Subject: [PATCH] browser-based admin setup on first visit, no CLI needed --- README.md | 8 +- packages/api/src/routes/admin/auth.ts | 61 +++++++++++++ packages/web/src/pages/admin/AdminLogin.tsx | 96 +++++++++++++++++---- 3 files changed, 142 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 78f2afe..144b1c0 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,9 @@ Then start it: 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 -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. +Put a reverse proxy in front for HTTPS (required for passkeys to work). ## What's included diff --git a/packages/api/src/routes/admin/auth.ts b/packages/api/src/routes/admin/auth.ts index d83c76d..5b1bd03 100644 --- a/packages/api/src/routes/admin/auth.ts +++ b/packages/api/src/routes/admin/auth.ts @@ -40,7 +40,68 @@ async function ensureLinkedUser(adminId: string): Promise { }, { 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) { + // 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 }>( + "/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 }>( "/admin/login", { config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } }, diff --git a/packages/web/src/pages/admin/AdminLogin.tsx b/packages/web/src/pages/admin/AdminLogin.tsx index 1516f86..5d0d495 100644 --- a/packages/web/src/pages/admin/AdminLogin.tsx +++ b/packages/web/src/pages/admin/AdminLogin.tsx @@ -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(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 (
- Admin Login + {needsSetup ? 'Welcome to Echoboard' : 'Admin Login'}

- {appName} administration + {needsSetup ? 'Create your admin account to get started' : `${appName} administration`}

-
+
- + setEmail(e.target.value)} autoComplete="email" + required aria-invalid={!!error} aria-describedby={error ? 'admin-login-error' : undefined} - style={{ borderColor: error ? 'var(--error)' : undefined }} />
- + 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 }} />
+ {needsSetup && ( +
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + required + aria-invalid={!!error} + aria-describedby={error ? 'admin-login-error' : undefined} + /> +
+ )} {error && ( @@ -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') + }