f706726eeb
A header A−/value/A+ control scales the whole UI, persisted per user and restored on login from any booth — cloning the theme-pref pattern end to end. - DB: users.font_scale (migration 0014; percent, 100 = base, NOT NULL default). - Server: PUT /api/auth/font-scale (auth-guarded; clamps to 80–160, snaps to a 10-step); fontScale flows through sessionView → login + /me. - Client: setFontScalePref + applyFontScale; applied in App alongside theme; FontScaleToggle in the header; i18n sq+en. Scaling uses CSS `zoom` on the root, NOT root font-size: the app's type is pinned in px (text-[12px] etc., ~230 spots), which a font-size change would not scale — so the dense Active-sessions / Live-feed logs stayed tiny. `zoom` scales everything uniformly (text, spacing, icons) like the browser's Ctrl+/−, which is the readability win for operators who need larger text. Tests: 4 font-scale auth-route cases (persist + /me, clamp/snap, 400, default-100). Full workspace build/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
248 lines
9.3 KiB
TypeScript
248 lines
9.3 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;
|
|
}
|
|
|
|
// 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. */
|
|
function sessionView(
|
|
db: Db,
|
|
user: {
|
|
id: string;
|
|
username: string;
|
|
roleId: string;
|
|
language: string;
|
|
theme: string;
|
|
fontScale: number;
|
|
fullName?: string | null;
|
|
email?: 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,
|
|
fontScale: user.fontScale,
|
|
fullName: user.fullName ?? null,
|
|
email: user.email ?? 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);
|
|
// 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);
|
|
});
|
|
|
|
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 };
|
|
},
|
|
);
|
|
|
|
// 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<string, string | null> = {};
|
|
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 };
|
|
},
|
|
);
|
|
}
|