import bcrypt from "bcrypt"; import type { FastifyInstance } from "fastify"; import { eq, roles, users, type Db } from "@parking/db"; import { clearAuthCookies, newCsrfToken, permissionsFor, requireAuth, setAuthCookies, } from "../auth.js"; // Local auth: username + bcrypt password → signed JWT in an HttpOnly cookie. // Fully offline; no external identity provider. See wiki/entities/local-jwt-auth.md. interface LoginBody { username: string; password: string; } const LANGS = ["sq", "en"] as const; type Lang = (typeof LANGS)[number]; interface LanguageBody { language: Lang; } const THEMES = ["dark", "light"] as const; type Theme = (typeof THEMES)[number]; interface ThemeBody { theme: Theme; } /** The session shape the SPA bootstraps from: identity + role + its permission * list (so the UI can gate nav/routes) + language. Role NAME is for display; the * permissions are the source of truth. */ function sessionView( db: Db, user: { id: string; username: string; roleId: string; language: string; theme: string; fullName?: string | null; }, ) { const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get(); const permissions = [...permissionsFor(user.roleId)]; return { id: user.id, username: user.username, roleId: user.roleId, roleName: role?.name ?? user.roleId, permissions, language: user.language, theme: user.theme, fullName: user.fullName ?? null, }; } export async function authRoutes(app: FastifyInstance, db: Db): Promise { app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => { const { username, password } = req.body ?? {}; if (!username || !password) { return reply.code(400).send({ error: "username and password required" }); } const user = await db.select().from(users).where(eq(users.username, username)).get(); // Always run a bcrypt compare to avoid leaking which usernames exist (timing). const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv"; const ok = await bcrypt.compare(password, hash); if (!user || !ok) { return reply.code(401).send({ error: "invalid credentials" }); } const csrf = newCsrfToken(); // No expiresIn: the token is valid until explicit logout (see auth.ts). The // token carries roleId (not the permission list) — perms resolve per-request, // so a role edit applies immediately with no re-login. const token = await reply.jwtSign({ sub: user.id, username: user.username, roleId: user.roleId, csrf, }); setAuthCookies(reply, token, csrf); // `language` is NOT in the JWT (identity/role only) — it's a mutable preference // read from the DB, so changing it needs no token refresh. return sessionView(db, user); }); app.post("/api/auth/logout", async (_req, reply) => { clearAuthCookies(reply); return { ok: true }; }); // Who am I — used by the SPA to bootstrap session state on load. Reads the live // `language` preference from the DB (not the token). app.get( "/api/auth/me", { preHandler: requireAuth }, async (req, reply) => { const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get(); if (!row) { // The user was deleted while their cookie was still valid — clear it. clearAuthCookies(reply); return reply.code(401).send({ error: "session no longer valid" }); } return sessionView(db, row); }, ); // Change MY own UI language preference (any signed-in user). Persisted to the // users row so it's restored on the next login, from any booth. See i18n.md. app.put<{ Body: LanguageBody }>( "/api/auth/language", { preHandler: requireAuth }, async (req, reply) => { const language = req.body?.language; if (!language || !LANGS.includes(language)) { return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` }); } await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run(); return { language }; }, ); // Change MY own UI theme preference (any signed-in user). Persisted to the users // row like `language`, so it's restored on the next login from any booth. app.put<{ Body: ThemeBody }>( "/api/auth/theme", { preHandler: requireAuth }, async (req, reply) => { const theme = req.body?.theme; if (!theme || !THEMES.includes(theme)) { return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` }); } await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run(); return { theme }; }, ); }