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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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" } } },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user