browser-based admin setup on first visit, no CLI needed
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -40,7 +40,68 @@ async function ensureLinkedUser(adminId: string): Promise<string> {
|
||||
}, { 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<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> }>(
|
||||
"/admin/login",
|
||||
{ config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user