import bcrypt from "bcrypt"; import type { FastifyInstance } from "fastify"; import { eq, roles, users, type Db } from "@parking/db"; import { effectiveModulesFor } from "../modules.js"; 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; } // UI font scale: percent of base, clamped to [80, 160] in steps of 10. Integer percent. const FONT_SCALE_MIN = 80; const FONT_SCALE_MAX = 160; interface FontScaleBody { fontScale: number; } // Self-service profile: a signed-in user edits their OWN display name + email. This is // NOT the admin user-management path (routes/users.ts) — it only ever touches the caller // (req.user.sub), needs no `user:*` permission, and can't change username, role, or any // other account. "" clears a field (→ null). See wiki/entities/local-jwt-auth.md. interface ProfileBody { fullName?: string | null; email?: string | null; } // Self-service password change: the user proves they hold the CURRENT password before // setting a new one — unlike the admin reset (users.ts), which sets it outright. This is // why it lives here and not behind a permission: it's account-self-care, not admin power. interface PasswordBody { currentPassword: string; newPassword: string; } const MIN_PASSWORD = 8; /** Trim a self-service profile string; "" (or whitespace) → null (clear the field). * Returns undefined for an absent key so an update only touches what was sent. */ function cleanProfileField(v: string | null | undefined): string | null | undefined { if (v === undefined) return undefined; const trimmed = typeof v === "string" ? v.trim() : ""; return trimmed === "" ? null : trimmed; } /** 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. * * `csrf`, when passed, echoes the SAME value already sent as the readable * parking_csrf cookie — not a new secret, just a second channel to learn it. * The desktop shell needs this: tauri-plugin-http's fetch() runs through * Rust's reqwest, which keeps its own cookie jar separate from the webview, * so document.cookie on the tauri://localhost page never sees a cookie set * on a plugin-routed response (open upstream bug, tauri-apps/tauri#13045). * The cookie itself IS still sent back to the server by reqwest on * subsequent requests — only the *client-side read* is broken — so * api.ts's desktop path stashes this body value in memory instead of * reading document.cookie, and echoes it in X-CSRF-Token exactly as the * browser path echoes the cookie. See lib/api.ts and assertCsrf() in * ../auth.ts (unchanged — this never touches verification, only how the * desktop client learns what to send). */ function sessionView( db: Db, user: { id: string; username: string; roleId: string; language: string; theme: string; fontScale: number; fullName?: string | null; email?: string | null; }, csrf?: string, ) { 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, fontScale: user.fontScale, fullName: user.fullName ?? null, email: user.email ?? null, // Effective venue modules (entitled ∩ activated) so the SPA can hide nav/routes // on first paint. The server still enforces via requireModule — this is display. modules: effectiveModulesFor(db), ...(csrf ? { csrfToken: csrf } : {}), }; } 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); // A soft-deleted user (in the recycle bin) cannot log in — treat as invalid, with no // distinct error so a deleted account isn't enumerable. if (!user || !ok || user.deletedAt) { 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, csrf); }); 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" }); } // req.user.csrf is the value bound into the JWT at login (see assertCsrf in // ../auth.ts) — same value as the cookie, re-surfaced for the desktop path. return sessionView(db, row, req.user.csrf); }, ); // 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 }; }, ); // Change MY own UI font scale (any signed-in user). Percent of base, clamped to // [80, 160] in steps of 10. Persisted like `theme`, restored on the next login. app.put<{ Body: FontScaleBody }>( "/api/auth/font-scale", { preHandler: requireAuth }, async (req, reply) => { const raw = req.body?.fontScale; if (typeof raw !== "number" || !Number.isFinite(raw)) { return reply.code(400).send({ error: "fontScale must be a number" }); } // Snap to a 10-step and clamp to the allowed band (defensive — the UI already does). const fontScale = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(raw / 10) * 10)); await db.update(users).set({ fontScale }).where(eq(users.id, req.user.sub)).run(); return { fontScale }; }, ); // Edit MY own display name / email (any signed-in user; no permission needed — it only // touches the caller). Cannot change username or role — those stay admin-only (users.ts). app.put<{ Body: ProfileBody }>( "/api/auth/profile", { preHandler: requireAuth }, async (req, reply) => { const fullName = cleanProfileField(req.body?.fullName); const email = cleanProfileField(req.body?.email); const patch: Record = {}; if (fullName !== undefined) patch.fullName = fullName; if (email !== undefined) patch.email = email; if (Object.keys(patch).length === 0) { return reply.code(400).send({ error: "nothing to update" }); } await db.update(users).set(patch).where(eq(users.id, req.user.sub)).run(); const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get(); if (!row) return reply.code(401).send({ error: "session no longer valid" }); return sessionView(db, row); }, ); // Change MY own password — must prove the CURRENT one first (defends against a walked-up, // already-logged-in booth: a passerby can't silently re-key the account). New password // >= MIN_PASSWORD. Distinct from the admin reset (users.ts), which needs no current pw. app.put<{ Body: PasswordBody }>( "/api/auth/password", { preHandler: requireAuth }, async (req, reply) => { const currentPassword = req.body?.currentPassword ?? ""; const newPassword = req.body?.newPassword ?? ""; if (newPassword.length < MIN_PASSWORD) { return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` }); } const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get(); if (!row) { clearAuthCookies(reply); return reply.code(401).send({ error: "session no longer valid" }); } const ok = await bcrypt.compare(currentPassword, row.passwordHash); if (!ok) { return reply.code(403).send({ error: "current password is incorrect" }); } const passwordHash = await bcrypt.hash(newPassword, 12); await db.update(users).set({ passwordHash }).where(eq(users.id, req.user.sub)).run(); return { ok: true }; }, ); }