Files
parking_solution/apps/server/src/routes/auth.ts
T
julian 040c0ff4ca feat: tabbed setup, user metadata, light theme, scoped shift history
Consolidate the config screens under a single /setup hub with permission-
gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing
the top nav to Booth·Shift·Setup; old top-level paths redirect.

Users: add optional profile metadata (full name, phone, email, address) on
create/edit. Theme: a light palette saved to the user's profile (users.theme),
toggled in the header beside the language switch and applied on load like the
language preference. Both ride on a single additive migration (0008).

Shift history: a new GET /api/shifts folds the signed shift_z_report chain into
completed shifts, SCOPED server-side — operators see only their own; holders of
shift:cash see all with an operator + date-range filter. Surfaced as the Shifts
tab; an operator cannot read another operator's takings (param spoofing is
ignored).

These three features share the router, api client and i18n catalogs, so they
land together. Verified live: theme persists across reload, metadata round-
trips to the DB, and shift scoping holds (operator self-only, admin all+filter).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:09:18 +02:00

143 lines
4.6 KiB
TypeScript

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<void> {
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 };
},
);
}