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

@@ -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" } } },