diff --git a/apps/server/scripts/seed-admin.mjs b/apps/server/scripts/seed-admin.mjs index a9c690f..035d2df 100644 --- a/apps/server/scripts/seed-admin.mjs +++ b/apps/server/scripts/seed-admin.mjs @@ -62,14 +62,14 @@ if (existing && process.env.FORCE !== "1") { const passwordHash = await bcrypt.hash(password, 12); if (existing) { - await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id)); + await db.update(users).set({ passwordHash, roleId: "admin" }).where(eq(users.id, existing.id)); console.log(`reset password for admin "${username}"`); } else { await db.insert(users).values({ id: randomUUID(), username, passwordHash, - role: "admin", + roleId: "admin", }); console.log(`created admin "${username}"`); } diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 397e9c5..1cba861 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -1,16 +1,22 @@ import { randomBytes } from "node:crypto"; import type { FastifyReply, FastifyRequest } from "fastify"; -import type { Role } from "@parking/shared"; +import { eq, rolePermissions, type Db } from "@parking/db"; +import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared"; // Local JWT auth helpers — fully local, no external identity provider // (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it); // a separate readable CSRF cookie + matching header defends mutations // (double-submit). See wiki/entities/local-jwt-auth.md. +// +// Authorization is DYNAMIC RBAC: the token carries the user's `roleId`, and each +// guarded route resolves that role's PERMISSION SET (cached in memory) and checks +// the permission it requires. Editing a role takes effect on the next request — +// no re-login, no token bloat, no stale perms. See @parking/shared PERMISSIONS. declare module "@fastify/jwt" { interface FastifyJWT { - payload: { sub: string; username: string; role: Role; csrf: string }; - user: { sub: string; username: string; role: Role; csrf: string }; + payload: { sub: string; username: string; roleId: string; csrf: string }; + user: { sub: string; username: string; roleId: string; csrf: string }; } } @@ -96,17 +102,84 @@ function assertCsrf(req: FastifyRequest): void { } } +// --- Permission resolution + cache ------------------------------------------- +// A role's permission set is read from `role_permissions` and cached in memory. +// SQLite is single-writer/single-process here, so a module-level Map is a correct +// cache: every role / role-permission mutation calls bumpPermsCache() to clear it, +// and the next request re-reads. The built-in `admin` role always resolves to the +// FULL permission set in code (never trusts the DB rows for it), so administration +// can't be accidentally narrowed. + +const ADMIN_PERMS: ReadonlySet = new Set(PERMISSIONS); +const permsCache = new Map>(); + +// The DB handle the permission resolver reads from. Set ONCE at startup via +// initAuth() so route guards don't each have to thread `db` (several route +// modules only receive a monitor/service, not the db). Single-process server. +let authDb: Db | null = null; + +/** Wire the permission resolver to the app's DB. Call once in buildServer(). */ +export function initAuth(db: Db): void { + authDb = db; + permsCache.clear(); +} + +/** Clear the permission cache. Call after ANY write to roles / role_permissions + * (or a user's roleId) so the change takes effect on the next request. */ +export function bumpPermsCache(): void { + permsCache.clear(); +} + +/** The permission set for a role id, cached. `admin` is always the full set. */ +export function permissionsFor(roleId: string): ReadonlySet { + if (roleId === ADMIN_ROLE_ID) return ADMIN_PERMS; + const hit = permsCache.get(roleId); + if (hit) return hit; + if (!authDb) throw new Error("auth not initialised (call initAuth)"); + const rows = authDb + .select({ permission: rolePermissions.permission }) + .from(rolePermissions) + .where(eq(rolePermissions.roleId, roleId)) + .all(); + const set = new Set(rows.map((r) => r.permission as Permission)); + permsCache.set(roleId, set); + return set; +} + +/** True if the role grants every listed permission. */ +export function roleHasPermissions( + roleId: string, + required: readonly Permission[], +): boolean { + const granted = permissionsFor(roleId); + return required.every((p) => granted.has(p)); +} + /** - * preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces - * CSRF on mutations, then checks the role. Authorization is a simple per-route - * role check — no Casbin/RBAC engine needed at this scale. + * preHandler permission guard. Verifies the JWT (from the HttpOnly cookie), + * enforces CSRF on mutations, then requires the user's role to grant ALL of the + * listed permissions. Authorization is a per-route permission check against the + * dynamic, admin-composed role grid — no Casbin/RBAC engine needed at this scale. */ -export function requireRole(...allowed: Role[]) { +export function requirePermission(...required: Permission[]) { return async (req: FastifyRequest, _reply: FastifyReply) => { await req.jwtVerify(); // reads the token cookie (configured in server.ts) assertCsrf(req); - if (!req.user || !allowed.includes(req.user.role)) { + if (!req.user || !roleHasPermissions(req.user.roleId, required)) { throw Object.assign(new Error("forbidden"), { statusCode: 403 }); } }; } + +/** + * preHandler that requires a valid signed-in session but NO specific permission — + * for "about me" routes (/me, change own language) every authenticated user may + * call regardless of role. Still enforces CSRF on mutations. + */ +export async function requireAuth( + req: FastifyRequest, + _reply: FastifyReply, +): Promise { + await req.jwtVerify(); + assertCsrf(req); +} diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 9272cd6..e467d1c 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,10 +1,11 @@ import bcrypt from "bcrypt"; import type { FastifyInstance } from "fastify"; -import { eq, users, type Db } from "@parking/db"; +import { eq, roles, users, type Db } from "@parking/db"; import { clearAuthCookies, newCsrfToken, - requireRole, + permissionsFor, + requireAuth, setAuthCookies, } from "../auth.js"; @@ -22,6 +23,22 @@ interface LanguageBody { language: Lang; } +/** 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 }) { + 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, + }; +} + export async function authRoutes(app: FastifyInstance, db: Db): Promise { app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => { const { username, password } = req.body ?? {}; @@ -39,17 +56,19 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { } const csrf = newCsrfToken(); - // No expiresIn: the token is valid until explicit logout (see auth.ts). + // 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, - role: user.role, + 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 { id: user.id, username: user.username, role: user.role, language: user.language }; + return sessionView(db, user); }); app.post("/api/auth/logout", async (_req, reply) => { @@ -61,11 +80,15 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { // `language` preference from the DB (not the token). app.get( "/api/auth/me", - { preHandler: requireRole("admin", "operator", "cashier", "readonly") }, - async (req) => { - const { sub, username, role } = req.user; - const row = await db.select().from(users).where(eq(users.id, sub)).get(); - return { id: sub, username, role, language: row?.language ?? "sq" }; + { 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); }, ); @@ -73,7 +96,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { // users row so it's restored on the next login, from any booth. See i18n.md. app.put<{ Body: LanguageBody }>( "/api/auth/language", - { preHandler: requireRole("admin", "operator", "cashier", "readonly") }, + { preHandler: requireAuth }, async (req, reply) => { const language = req.body?.language; if (!language || !LANGS.includes(language)) { diff --git a/apps/server/src/routes/device-status.ts b/apps/server/src/routes/device-status.ts index d417685..e85f440 100644 --- a/apps/server/src/routes/device-status.ts +++ b/apps/server/src/routes/device-status.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; import type { DeviceMonitor } from "../device-monitor.js"; // Unified device-status snapshot for the booth footer. The DeviceMonitor polls all @@ -13,7 +13,7 @@ export async function deviceStatusRoutes( app: FastifyInstance, monitor: DeviceMonitor, ): Promise { - const guard = requireRole("admin", "operator", "cashier", "readonly"); + const guard = requirePermission("device:read"); app.get("/api/devices/status", { preHandler: guard }, async () => ({ devices: monitor.snapshot(), diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index a563d11..b2f3820 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { desc, gte, ledgerEvents, type Db } from "@parking/db"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; import type { EventLog } from "../event-log.js"; // Read access to the append-only signed event log. NO write/update/delete routes @@ -13,8 +13,8 @@ export async function eventRoutes( db: Db, eventLog: EventLog, ): Promise { - // Any authenticated role may read the log (it's the audit trail). - const guard = requireRole("admin", "operator", "cashier", "readonly"); + // Reading the log (the audit trail). + const guard = requirePermission("event:read"); // Recent events, newest first. `limit` caps the page (default 100, max 1000). // Optional `since` (ISO) scopes the page to events at/after that instant — the @@ -42,7 +42,7 @@ export async function eventRoutes( // reconciliation job / "is the log intact?" check calls. app.get( "/api/events/verify", - { preHandler: requireRole("admin") }, + { preHandler: requirePermission("event:read") }, async () => eventLog.verifyChain(), ); } diff --git a/apps/server/src/routes/pay.ts b/apps/server/src/routes/pay.ts index 7c080f6..d683f30 100644 --- a/apps/server/src/routes/pay.ts +++ b/apps/server/src/routes/pay.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; import { NoPrinterAvailableError } from "@parking/devices"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; import { NoOpenSessionError, NoTariffError, @@ -44,8 +44,12 @@ export async function payRoutes( exitFlow: ExitFlow, shift: ShiftService, ): Promise { - // Cashier/operator/admin operate the booth; readonly may not. - const guard = requireRole("admin", "operator", "cashier"); + // Reads (lookup, active sessions, quote) need session/payment read; the booth + // money actions (pay, exit, voucher, receipt, reopen) need payment:create. A + // single guard covers the whole booth flow — anyone who takes payment also reads + // sessions. Read-only callers (a viewer role) get the reads but not the actions. + const guard = requirePermission("payment:create"); + const readGuard = requirePermission("session:read"); // Money-path gate: a shift must be open site-wide before any payment/exit/voucher/ // re-open is processed, so every taking is attributed to a shift (one operator's @@ -70,7 +74,7 @@ export async function payRoutes( // Active sessions for the booth list: still-open OR exited-but-within-grace // (barrier unconfirmed → a paid/exited car is presumed possibly-present until // grace expires). Read-only. See wiki/concepts/booth-exit-flow.md. - app.get("/api/sessions/active", { preHandler: guard }, async () => ({ + app.get("/api/sessions/active", { preHandler: readGuard }, async () => ({ sessions: payStation.activeSessions(), })); @@ -78,7 +82,7 @@ export async function payRoutes( // amount owed now, walk-back-grace status. Read-only (no side effect). app.get<{ Params: { identity: string } }>( "/api/session/:identity", - { preHandler: guard }, + { preHandler: readGuard }, async (req, reply) => { const identity = (req.params.identity ?? "").trim(); if (!identity) return reply.code(400).send({ error: "identity required" }); @@ -124,7 +128,7 @@ export async function payRoutes( // Quote: what does this session owe right now? (No side effect.) app.get<{ Querystring: QuoteQuery }>( "/api/pay/quote", - { preHandler: guard }, + { preHandler: readGuard }, async (req, reply) => { const identity = (req.query.identity ?? "").trim(); if (!identity) return reply.code(400).send({ error: "identity required" }); diff --git a/apps/server/src/routes/printers.ts b/apps/server/src/routes/printers.ts index c24aaa7..f83959a 100644 --- a/apps/server/src/routes/printers.ts +++ b/apps/server/src/routes/printers.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; import { deviceEvents } from "../device-events.js"; import type { PrinterMonitor } from "../printer-monitor.js"; @@ -12,7 +12,7 @@ export async function printerRoutes( app: FastifyInstance, monitor: PrinterMonitor, ): Promise { - const guard = requireRole("admin", "operator", "cashier", "readonly"); + const guard = requirePermission("device:read"); // Current status of every monitored printer (cached — no device round-trip). app.get("/api/printers/status", { preHandler: guard }, async () => ({ diff --git a/apps/server/src/routes/roles.ts b/apps/server/src/routes/roles.ts new file mode 100644 index 0000000..66ef977 --- /dev/null +++ b/apps/server/src/routes/roles.ts @@ -0,0 +1,147 @@ +import { randomUUID } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import { eq, rolePermissions, roles, users, type Db } from "@parking/db"; +import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared"; +import { bumpPermsCache, requirePermission } from "../auth.js"; + +// Role management (admin). Roles are DATA: an admin composes a role from the +// code-defined PERMISSIONS grid (resource:action), and users are assigned one +// role. The built-in `admin` role (id ADMIN_ROLE_ID) is PROTECTED — it can't be +// edited or deleted and always resolves to every permission in code. Every write +// here bumps the in-memory permission cache so changes take effect on the next +// request. See @parking/shared PERMISSIONS and ../auth.ts. + +interface RoleBody { + name: string; + permissions: string[]; +} +interface UpdateBody { + name?: string; + permissions?: string[]; +} + +const VALID = new Set(PERMISSIONS); + +/** Validate + dedupe a requested permission list against the code-defined grid. */ +function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | { ok: false; bad: string } { + if (!Array.isArray(input)) return { ok: false, bad: "permissions must be an array" }; + const out = new Set(); + for (const p of input) { + if (typeof p !== "string" || !VALID.has(p)) return { ok: false, bad: `unknown permission: ${String(p)}` }; + out.add(p as Permission); + } + return { ok: true, perms: [...out] }; +} + +export async function roleRoutes(app: FastifyInstance, db: Db): Promise { + const readGuard = requirePermission("role:read"); + const createGuard = requirePermission("role:create"); + const updateGuard = requirePermission("role:update"); + const deleteGuard = requirePermission("role:delete"); + + /** A role + its permission list + how many users hold it. */ + function roleView(roleId: string) { + const role = db.select().from(roles).where(eq(roles.id, roleId)).get(); + if (!role) return null; + const perms = db + .select({ permission: rolePermissions.permission }) + .from(rolePermissions) + .where(eq(rolePermissions.roleId, roleId)) + .all() + .map((r) => r.permission); + const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length; + // The admin role always reports the full grid (it's enforced in code). + return { + id: role.id, + name: role.name, + builtin: role.builtin === 1, + permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms, + userCount, + }; + } + + /** Replace a role's permission rows with `perms` (in a single pass). */ + function setPermissions(roleId: string, perms: Permission[]): void { + db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run(); + for (const p of perms) { + db.insert(rolePermissions).values({ roleId, permission: p }).run(); + } + } + + // The full permission grid (for the role-composer checkbox UI) + every role. + app.get("/api/roles", { preHandler: readGuard }, async () => { + const all = db.select().from(roles).all(); + return { + catalog: PERMISSIONS, + roles: all.map((r) => roleView(r.id)).filter((r) => r != null), + }; + }); + + // Create a composable role from a name + a permission set. + app.post<{ Body: RoleBody }>("/api/roles", { preHandler: createGuard }, async (req, reply) => { + const name = (req.body?.name ?? "").trim(); + if (!name) return reply.code(400).send({ error: "name required" }); + if (db.select().from(roles).where(eq(roles.name, name)).get()) { + return reply.code(409).send({ error: "a role with that name already exists" }); + } + const cleaned = cleanPermissions(req.body?.permissions ?? []); + if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad }); + + const id = randomUUID(); + db.insert(roles).values({ id, name, builtin: 0 }).run(); + setPermissions(id, cleaned.perms); + bumpPermsCache(); + return reply.code(201).send(roleView(id)); + }); + + // Edit a role's name and/or permission set. The built-in admin role is locked. + app.put<{ Params: { id: string }; Body: UpdateBody }>( + "/api/roles/:id", + { preHandler: updateGuard }, + async (req, reply) => { + const id = req.params.id; + const role = db.select().from(roles).where(eq(roles.id, id)).get(); + if (!role) return reply.code(404).send({ error: "role not found" }); + if (role.builtin === 1) { + return reply.code(409).send({ error: "the built-in admin role cannot be edited" }); + } + + if (req.body?.name != null) { + const name = req.body.name.trim(); + if (!name) return reply.code(400).send({ error: "name cannot be empty" }); + const clash = db.select().from(roles).where(eq(roles.name, name)).get(); + if (clash && clash.id !== id) return reply.code(409).send({ error: "a role with that name already exists" }); + db.update(roles).set({ name }).where(eq(roles.id, id)).run(); + } + if (req.body?.permissions != null) { + const cleaned = cleanPermissions(req.body.permissions); + if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad }); + setPermissions(id, cleaned.perms); + } + bumpPermsCache(); + return roleView(id); + }, + ); + + // Delete a role. Refused if it's built-in or any user still holds it. + app.delete<{ Params: { id: string } }>( + "/api/roles/:id", + { preHandler: deleteGuard }, + async (req, reply) => { + const id = req.params.id; + const role = db.select().from(roles).where(eq(roles.id, id)).get(); + if (!role) return reply.code(404).send({ error: "role not found" }); + if (role.builtin === 1) { + return reply.code(409).send({ error: "the built-in admin role cannot be deleted" }); + } + const holders = db.select().from(users).where(eq(users.roleId, id)).all().length; + if (holders > 0) { + return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` }); + } + db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run(); + db.delete(roles).where(eq(roles.id, id)).run(); + bumpPermsCache(); + return { ok: true }; + }, + ); +} diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 11958b8..16eb960 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -12,7 +12,7 @@ import { type DeviceCategory, type DeviceConfig, } from "@parking/devices"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; // First-run setup API. The admin reads the driver catalog and assigns devices @@ -176,8 +176,9 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); - // Setup endpoints require an admin (cookie-based JWT — see ../auth.ts). - const adminGuard = requireRole("admin"); + // Device setup is site administration — it changes which hardware the site runs + // and how readers bind to relays. Gated on site:update. See ../auth.ts. + const adminGuard = requirePermission("site:update"); // Catalog of selectable drivers per category (no secrets — schema only). // `discoverable` flags drivers that can scan the LAN; `pushCapable` flags diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index 6f9690c..ff8e1cf 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; import { InvalidCashMovementError, NoOpenShiftError, @@ -19,8 +19,9 @@ interface CashMovementBody { // local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it. export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise { - // Cashier/operator/admin run shifts; readonly can't. - const guard = requireRole("admin", "operator", "cashier"); + // Reading the shift state vs. opening/closing one's own shift. + const readGuard = requirePermission("shift:read"); + const guard = requirePermission("shift:create"); // The SITE-WIDE shift state (at most one shift open at a time). The UI uses this // to render the header control: no shift → "Open"; my shift → "Close" (enabled); @@ -28,7 +29,7 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr // - open: the open shift { startedAt, operator } or null (site-wide) // - isMine: true iff the open shift belongs to the requesting operator // - operator: the requesting user (for the UI's own identity) - app.get("/api/shift/current", { preHandler: guard }, async (req) => { + app.get("/api/shift/current", { preHandler: readGuard }, async (req) => { const me = req.user.username; const open = shift.currentOpenShift(); const heldBy = open?.identity ?? null; @@ -47,7 +48,7 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr // amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md. app.post<{ Body: CashMovementBody }>( "/api/cash-movement", - { preHandler: requireRole("admin") }, + { preHandler: requirePermission("shift:cash") }, async (req, reply) => { const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody); try { diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index c7a5cd2..f983982 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { eq, siteConfig, type Db } from "@parking/db"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; import { getOccupancy } from "../occupancy.js"; // Site config (capacity) + live occupancy. Occupancy is a fold over the signed @@ -57,8 +57,8 @@ function normText(v: unknown): string | null { } export async function siteRoutes(app: FastifyInstance, db: Db): Promise { - const readGuard = requireRole("admin", "operator", "cashier", "readonly"); - const writeGuard = requireRole("admin"); + const readGuard = requirePermission("site:read"); + const writeGuard = requirePermission("site:update"); // Live occupancy: cars inside, capacity, free, full. Any signed-in role. app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db)); diff --git a/apps/server/src/routes/snapshots.ts b/apps/server/src/routes/snapshots.ts index f0e116c..3b7eaf9 100644 --- a/apps/server/src/routes/snapshots.ts +++ b/apps/server/src/routes/snapshots.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { desc, eq, snapshots, type Db } from "@parking/db"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; // Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see // packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence @@ -9,7 +9,7 @@ import { requireRole } from "../auth.js"; // never via the API. export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise { - const guard = requireRole("admin", "operator", "cashier", "readonly"); + const guard = requirePermission("session:read"); // Snapshot metadata for one session/credential identity (NOT the bytes), newest // first — lets the UI show "entry/exit image" links beside an event. diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index 8d33740..1c4e2d4 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { NoPrinterAvailableError } from "@parking/devices"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; import { printSubscriptionCard } from "../booth-print.js"; import type { CredentialCapture } from "../credential-capture.js"; import { directionOf } from "../device-resolve.js"; @@ -71,9 +71,11 @@ export async function subscriptionRoutes( db: Db, capture: CredentialCapture, ): Promise { - // Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up). - const readGuard = requireRole("admin", "operator", "cashier", "readonly"); - const writeGuard = requireRole("admin"); + // Reading/looking up subscriptions vs. managing them. Revoke folds into update. + const readGuard = requirePermission("subscription:read"); + const createGuard = requirePermission("subscription:create"); + const updateGuard = requirePermission("subscription:update"); + const deleteGuard = requirePermission("subscription:delete"); // Validate the body; returns problems (empty = ok). Shared by create + update. function validate(b: SubscriptionBody): string[] { @@ -223,7 +225,7 @@ export async function subscriptionRoutes( }); // Create a subscription. - app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: writeGuard }, async (req, reply) => { + app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => { const b = req.body ?? {}; const problems = validate(b); if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems }); @@ -281,7 +283,7 @@ export async function subscriptionRoutes( // Update a subscription (replaces fields + child sets). app.put<{ Params: { id: string }; Body: SubscriptionBody }>( "/api/subscriptions/:id", - { preHandler: writeGuard }, + { preHandler: updateGuard }, async (req, reply) => { const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get(); if (!existing) return reply.code(404).send({ error: "subscription not found" }); @@ -342,7 +344,7 @@ export async function subscriptionRoutes( // DELETE only to fully remove one created in error. app.post<{ Params: { id: string } }>( "/api/subscriptions/:id/revoke", - { preHandler: writeGuard }, + { preHandler: updateGuard }, async (req, reply) => { const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run(); if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" }); @@ -354,7 +356,7 @@ export async function subscriptionRoutes( // are untouched — the audit trail is append-only and independent of this row.) app.delete<{ Params: { id: string } }>( "/api/subscriptions/:id", - { preHandler: writeGuard }, + { preHandler: deleteGuard }, async (req, reply) => { const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run(); if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" }); diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts index 6cea217..5749fc0 100644 --- a/apps/server/src/routes/tariffs.ts +++ b/apps/server/src/routes/tariffs.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared"; -import { requireRole } from "../auth.js"; +import { requirePermission } from "../auth.js"; /** Default site timezone for wall-clock tariff windows when none is configured. */ const DEFAULT_TZ = "Europe/Tirane"; @@ -23,10 +23,10 @@ interface PublishBody { const SITE_TARIFF_NAME = "Site tariff"; export async function tariffRoutes(app: FastifyInstance, db: Db): Promise { - // Any signed-in role may READ the tariff (the pay station / operator UI needs it). - const readGuard = requireRole("admin", "operator", "cashier", "readonly"); - // Only an admin may PUBLISH a new version (it changes what customers are charged). - const writeGuard = requireRole("admin"); + // Reading the rate card (pay station / operator UI needs it). + const readGuard = requirePermission("tariff:read"); + // Publishing a new version changes what customers are charged. + const writeGuard = requirePermission("tariff:update"); // The single site tariff row, created on first read/publish. function ensureSiteTariff(): string { diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts new file mode 100644 index 0000000..12f3ee9 --- /dev/null +++ b/apps/server/src/routes/users.ts @@ -0,0 +1,159 @@ +import { randomUUID } from "node:crypto"; +import bcrypt from "bcrypt"; +import type { FastifyInstance } from "fastify"; +import { eq, roles, users, type Db } from "@parking/db"; +import { ADMIN_ROLE_ID } from "@parking/shared"; +import { requirePermission } from "../auth.js"; + +// User management (admin). Users are created/edited at runtime here — the +// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one +// role (RBAC); the role resolves to a permission set at request time. Passwords +// are bcrypt-hashed (cost 12) and never returned. See @parking/shared PERMISSIONS. +// +// NO-LOCKOUT INVARIANT: the app refuses to delete, or move off the `admin` role, +// the LAST user still holding `admin`. Administration can therefore never be +// locked out of the appliance. See wiki/entities/local-jwt-auth.md. + +interface CreateBody { + username: string; + password: string; + roleId: string; +} +interface UpdateBody { + username?: string; + roleId?: string; +} +interface PasswordBody { + password: string; +} + +const MIN_PASSWORD = 8; + +export async function userRoutes(app: FastifyInstance, db: Db): Promise { + const readGuard = requirePermission("user:read"); + const createGuard = requirePermission("user:create"); + const updateGuard = requirePermission("user:update"); + const deleteGuard = requirePermission("user:delete"); + + /** Count users currently holding the protected admin role. */ + function adminCount(): number { + return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length; + } + + /** True if removing/relocating `userId` from admin would leave zero admins. */ + function isLastAdmin(userId: string): boolean { + const u = db.select().from(users).where(eq(users.id, userId)).get(); + return u?.roleId === ADMIN_ROLE_ID && adminCount() <= 1; + } + + /** A user row safe to return — never the password hash. */ + function publicUser(u: { id: string; username: string; roleId: string; language: string; createdAt: string }) { + return { id: u.id, username: u.username, roleId: u.roleId, language: u.language, createdAt: u.createdAt }; + } + + // List all users (no password hashes) + their role names for display. + app.get("/api/users", { preHandler: readGuard }, async () => { + const rows = db.select().from(users).all(); + const roleRows = db.select().from(roles).all(); + const roleName = new Map(roleRows.map((r) => [r.id, r.name])); + return { + users: rows.map((u) => ({ ...publicUser(u), roleName: roleName.get(u.roleId) ?? u.roleId })), + }; + }); + + // Create a user. Username unique; password >= 8 chars; roleId must exist. + app.post<{ Body: CreateBody }>("/api/users", { preHandler: createGuard }, async (req, reply) => { + const username = (req.body?.username ?? "").trim(); + const password = req.body?.password ?? ""; + const roleId = (req.body?.roleId ?? "").trim(); + if (!username || !roleId) { + return reply.code(400).send({ error: "username and roleId required" }); + } + if (password.length < MIN_PASSWORD) { + return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` }); + } + if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) { + return reply.code(400).send({ error: "unknown roleId" }); + } + if (db.select().from(users).where(eq(users.username, username)).get()) { + return reply.code(409).send({ error: "username already exists" }); + } + const id = randomUUID(); + const passwordHash = await bcrypt.hash(password, 12); + db.insert(users).values({ id, username, passwordHash, roleId }).run(); + const created = db.select().from(users).where(eq(users.id, id)).get()!; + return reply.code(201).send(publicUser(created)); + }); + + // Update a user's username and/or role. Guarded against orphaning admin. + app.put<{ Params: { id: string }; Body: UpdateBody }>( + "/api/users/:id", + { preHandler: updateGuard }, + async (req, reply) => { + const id = req.params.id; + const existing = db.select().from(users).where(eq(users.id, id)).get(); + if (!existing) return reply.code(404).send({ error: "user not found" }); + + const next: { username?: string; roleId?: string } = {}; + if (req.body?.username != null) { + const username = req.body.username.trim(); + if (!username) return reply.code(400).send({ error: "username cannot be empty" }); + const clash = db.select().from(users).where(eq(users.username, username)).get(); + if (clash && clash.id !== id) return reply.code(409).send({ error: "username already exists" }); + next.username = username; + } + if (req.body?.roleId != null) { + const roleId = req.body.roleId.trim(); + if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) { + return reply.code(400).send({ error: "unknown roleId" }); + } + // No-lockout: don't move the last admin off the admin role. + if (roleId !== ADMIN_ROLE_ID && isLastAdmin(id)) { + return reply.code(409).send({ error: "cannot change the role of the last admin" }); + } + next.roleId = roleId; + } + if (Object.keys(next).length === 0) { + return reply.code(400).send({ error: "nothing to update" }); + } + db.update(users).set(next).where(eq(users.id, id)).run(); + return publicUser(db.select().from(users).where(eq(users.id, id)).get()!); + }, + ); + + // Reset a user's password (admin sets a new one; >= 8 chars). + app.put<{ Params: { id: string }; Body: PasswordBody }>( + "/api/users/:id/password", + { preHandler: updateGuard }, + async (req, reply) => { + const id = req.params.id; + if (!db.select().from(users).where(eq(users.id, id)).get()) { + return reply.code(404).send({ error: "user not found" }); + } + const password = req.body?.password ?? ""; + if (password.length < MIN_PASSWORD) { + return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` }); + } + const passwordHash = await bcrypt.hash(password, 12); + db.update(users).set({ passwordHash }).where(eq(users.id, id)).run(); + return { ok: true }; + }, + ); + + // Delete a user. Refused if it's the last admin (no-lockout). + app.delete<{ Params: { id: string } }>( + "/api/users/:id", + { preHandler: deleteGuard }, + async (req, reply) => { + const id = req.params.id; + if (!db.select().from(users).where(eq(users.id, id)).get()) { + return reply.code(404).send({ error: "user not found" }); + } + if (isLastAdmin(id)) { + return reply.code(409).send({ error: "cannot delete the last admin" }); + } + db.delete(users).where(eq(users.id, id)).run(); + return { ok: true }; + }, + ); +} diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index 0b18d32..c523a1c 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; -import type { Role } from "@parking/shared"; +import { roleHasPermissions } from "../auth.js"; import { deviceEvents } from "../device-events.js"; import type { DeviceMonitor } from "../device-monitor.js"; import { getOccupancy } from "../occupancy.js"; @@ -23,9 +23,9 @@ import { getOccupancy } from "../occupancy.js"; // allowed booth UI origin). Non-browser clients (no Origin) are rejected too. // See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md. -/** Roles allowed to watch the live feed (everyone signed in; readonly included — - * it's a read-only stream). */ -const WATCH_ROLES: Role[] = ["admin", "operator", "cashier", "readonly"]; +/** Permission required to watch the live feed (a read-only stream of ledger + + * device status). Any role granted `report:read` may watch. */ +const WATCH_PERMISSION = "report:read" as const; /** * Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is @@ -68,7 +68,7 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi throw Object.assign(new Error("forbidden origin"), { statusCode: 403 }); } await req.jwtVerify(); - if (!req.user || !WATCH_ROLES.includes(req.user.role)) { + if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) { throw Object.assign(new Error("forbidden"), { statusCode: 403 }); } }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f893659..5b14b89 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,7 +4,7 @@ import websocket from "@fastify/websocket"; import Fastify, { type FastifyInstance } from "fastify"; import { randomUUID } from "node:crypto"; import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; -import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js"; +import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js"; import { deviceEvents } from "./device-events.js"; import { EntryFlow } from "./entry-flow.js"; import { EventLog } from "./event-log.js"; @@ -18,6 +18,8 @@ import { PrinterMonitor } from "./printer-monitor.js"; import { DeviceMonitor } from "./device-monitor.js"; import { buildSigner, buildVerifier } from "./signer.js"; import { authRoutes } from "./routes/auth.js"; +import { userRoutes } from "./routes/users.js"; +import { roleRoutes } from "./routes/roles.js"; import { deviceRoutes } from "./routes/devices.js"; import { eventRoutes } from "./routes/events.js"; import { payRoutes } from "./routes/pay.js"; @@ -47,6 +49,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise { + const out: Record = {}; + for (const p of perms) { + const resource = p.split(":")[0]!; + (out[resource] ??= []).push(p); + } + return out; +} + +export function RolesManager({ user }: { user: SessionUser | null }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const rolesQ = useQuery({ queryKey: ["roles"], queryFn: fetchRoles }); + + const canCreate = can(user, "role:create"); + const canUpdate = can(user, "role:update"); + const canDelete = can(user, "role:delete"); + + const catalog = rolesQ.data?.catalog ?? []; + const roles = rolesQ.data?.roles ?? []; + const grouped = useMemo(() => groupByResource(catalog), [catalog]); + + const [error, setError] = useState(null); + const [editing, setEditing] = useState(null); + + const invalidate = () => { + void qc.invalidateQueries({ queryKey: ["roles"] }); + void qc.invalidateQueries({ queryKey: ["users"] }); + }; + const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message); + + return ( +
+
+

{t("roles.title")}

+ {canCreate && ( + + )} +
+ + {error &&
{error}
} + + {editing && ( + setEditing(null)} + onSubmit={async (v) => { + try { + if (editing === "new") await createRole(v); + else await updateRole(editing.id, v); + setEditing(null); + invalidate(); + } catch (e) { onError(e); } + }} + /> + )} + +
+ {roles.map((r) => ( +
+
+
+ {r.name} + {r.builtin && ( + + {t("roles.builtin")} + + )} + + {t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })} + +
+
+ {canUpdate && !r.builtin && ( + + )} + {canDelete && !r.builtin && ( + + )} +
+
+
+ ))} +
+
+ ); +} + +async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown) => void) { + try { await deleteRole(id); ok(); } catch (e) { onError(e); } +} + +function RoleEditor({ + role, grouped, onCancel, onSubmit, +}: { + role: ManagedRole | null; + grouped: Record; + onCancel: () => void; + onSubmit: (v: { name: string; permissions: Permission[] }) => void; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(role?.name ?? ""); + const [perms, setPerms] = useState>(new Set(role?.permissions ?? [])); + const toggle = (p: Permission) => + setPerms((prev) => { + const next = new Set(prev); + next.has(p) ? next.delete(p) : next.add(p); + return next; + }); + + const valid = name.trim().length > 0; + + return ( +
+
+ {role ? t("roles.editTitle") : t("roles.new")} +
+ + +
{t("roles.permissions")}
+
+ {Object.entries(grouped).map(([resource, list]) => ( +
+ {resource} + {list.map((p) => { + const action = p.split(":")[1]!; + return ( + + ); + })} +
+ ))} +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/UsersManager.tsx b/apps/web/src/UsersManager.tsx new file mode 100644 index 0000000..7a5989e --- /dev/null +++ b/apps/web/src/UsersManager.tsx @@ -0,0 +1,232 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ApiError, + can, + createUser, + deleteUser, + fetchRoles, + fetchUsers, + resetUserPassword, + updateUser, + type ManagedRole, + type ManagedUser, + type SessionUser, +} from "./api.js"; + +// User management (admin). List users, create one (username + password + role), +// change a user's role, reset a password, delete. The server enforces the same +// permissions and the no-lockout rule (the last admin can't be removed). See +// wiki/entities/local-jwt-auth.md. + +export function UsersManager({ user }: { user: SessionUser | null }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const usersQ = useQuery({ queryKey: ["users"], queryFn: fetchUsers }); + const rolesQ = useQuery({ queryKey: ["roles"], queryFn: fetchRoles }); + + const canCreate = can(user, "user:create"); + const canUpdate = can(user, "user:update"); + const canDelete = can(user, "user:delete"); + + const roles: ManagedRole[] = rolesQ.data?.roles ?? []; + const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + + const invalidate = () => void qc.invalidateQueries({ queryKey: ["users"] }); + const onError = (e: unknown) => + setError(e instanceof ApiError ? e.message : (e as Error).message); + + return ( +
+
+

{t("users.title")}

+ {canCreate && roles.length > 0 && ( + + )} +
+ + {error &&
{error}
} + + {adding && ( + setAdding(false)} + onSubmit={async (v) => { + try { + await createUser({ username: v.username, password: v.password, roleId: v.roleId }); + setAdding(false); + invalidate(); + } catch (e) { onError(e); } + }} + /> + )} + +
+ + + + + + + + + + {(usersQ.data?.users ?? []).map((u) => ( + + ))} + {usersQ.data?.users.length === 0 && ( + + )} + +
{t("users.username")}{t("users.role")}{t("common.none")}
{t("users.none")}
+
+
+ ); +} + +function UserRow({ + u, roles, canUpdate, canDelete, onChanged, onError, +}: { + u: ManagedUser; + roles: ManagedRole[]; + canUpdate: boolean; + canDelete: boolean; + onChanged: () => void; + onError: (e: unknown) => void; +}) { + const { t } = useTranslation(); + const [resetting, setResetting] = useState(false); + const [pw, setPw] = useState(""); + + const roleMut = useMutation({ + mutationFn: (roleId: string) => updateUser(u.id, { roleId }), + onSuccess: onChanged, + onError, + }); + const pwMut = useMutation({ + mutationFn: () => resetUserPassword(u.id, pw), + onSuccess: () => { setResetting(false); setPw(""); }, + onError, + }); + const delMut = useMutation({ + mutationFn: () => deleteUser(u.id), + onSuccess: onChanged, + onError, + }); + + return ( + + {u.username} + + {canUpdate ? ( + + ) : ( + u.roleName + )} + + +
+ {canUpdate && !resetting && ( + + )} + {canUpdate && resetting && ( + + setPw(e.target.value)} + placeholder={t("users.newPassword")} + className="w-32 rounded-term border border-term-border bg-term-panel px-2 py-0.5 text-[12px]" + /> + + + + )} + {canDelete && ( + + )} +
+ + + ); +} + +function UserForm({ + roles, onCancel, onSubmit, +}: { + roles: ManagedRole[]; + onCancel: () => void; + onSubmit: (v: { username: string; password: string; roleId: string }) => void; +}) { + const { t } = useTranslation(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [roleId, setRoleId] = useState(roles[0]?.id ?? ""); + const valid = username.trim().length > 0 && password.length >= 8 && roleId; + + return ( +
+
{t("users.new")}
+
+ + + +
+
{t("users.passwordHint")}
+
+ + +
+
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 09ec1cd..870b2c8 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -44,16 +44,26 @@ export class ApiError extends Error { // --- Auth ----------------------------------------------------------------- -export type Role = "admin" | "operator" | "cashier" | "readonly"; export type Lang = "sq" | "en"; +/** A `resource:action` permission string (the server is the source of truth for + * the full grid; the role composer fetches it via /api/roles). */ +export type Permission = string; export interface SessionUser { id: string; username: string; - role: Role; + roleId: string; + roleName: string; + /** The permissions this user's role grants — the UI gates nav/routes on these. */ + permissions: Permission[]; /** Preferred UI language (loaded from the server on login). */ language: Lang; } +/** Does this session grant the permission? Central authz check for the SPA. */ +export function can(user: SessionUser | null, perm: Permission): boolean { + return !!user && user.permissions.includes(perm); +} + export function login(username: string, password: string): Promise { return apiFetch("/api/auth/login", { method: "POST", @@ -80,6 +90,54 @@ export async function fetchMe(): Promise { } } +// --- User & role management (RBAC) ---------------------------------------- + +export interface ManagedUser { + id: string; + username: string; + roleId: string; + roleName: string; + language: Lang; + createdAt: string; +} +export interface ManagedRole { + id: string; + name: string; + builtin: boolean; + permissions: Permission[]; + userCount: number; +} + +export function fetchUsers(): Promise<{ users: ManagedUser[] }> { + return apiFetch("/api/users"); +} +export function createUser(body: { username: string; password: string; roleId: string }): Promise { + return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) }); +} +export function updateUser(id: string, body: { username?: string; roleId?: string }): Promise { + return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) }); +} +export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> { + return apiFetch(`/api/users/${id}/password`, { method: "PUT", body: JSON.stringify({ password }) }); +} +export function deleteUser(id: string): Promise<{ ok: boolean }> { + return apiFetch(`/api/users/${id}`, { method: "DELETE" }); +} + +/** Roles + the full permission catalog (for the composer checkbox grid). */ +export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> { + return apiFetch("/api/roles"); +} +export function createRole(body: { name: string; permissions: Permission[] }): Promise { + return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) }); +} +export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise { + return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) }); +} +export function deleteRole(id: string): Promise<{ ok: boolean }> { + return apiFetch(`/api/roles/${id}`, { method: "DELETE" }); +} + // --- Device setup --------------------------------------------------------- export interface ConfigField { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index af19b03..50ab659 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -26,6 +26,8 @@ export const en: Catalog = { tariff: "Tariff", subscriptions: "Subscriptions", site: "Site", + users: "Users", + roles: "Roles", }, status: { live: "LIVE", @@ -227,6 +229,36 @@ export const en: Catalog = { fieldPhone: "Phone", fieldEmail: "Email", }, + users: { + title: "Users", + add: "+ Add user", + new: "New user", + none: "No users.", + username: "Username", + password: "Password", + passwordHint: "At least 8 characters.", + newPassword: "new password", + role: "Role", + resetPassword: "Reset password", + delete: "Delete", + confirmDelete: "Delete user \"{{name}}\"?", + }, + roles: { + title: "Roles", + add: "+ Add role", + new: "New role", + editTitle: "Edit role", + name: "Name", + permissions: "Permissions", + builtin: "built-in", + edit: "Edit", + delete: "Delete", + confirmDelete: "Delete role \"{{name}}\"?", + permCount_one: "{{count}} permission", + permCount_other: "{{count}} permissions", + userCount_one: "{{count}} user", + userCount_other: "{{count}} users", + }, shift: { label: "Shift:", open: "open", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 6d1716f..f9e9e92 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -26,6 +26,8 @@ export const sq = { tariff: "Tarifa", subscriptions: "Abonimet", site: "Vendi", + users: "Përdoruesit", + roles: "Rolet", }, status: { live: "LIVE", @@ -229,6 +231,36 @@ export const sq = { fieldPhone: "Telefoni", fieldEmail: "Email", }, + users: { + title: "Përdoruesit", + add: "+ Shto përdorues", + new: "Përdorues i ri", + none: "Asnjë përdorues.", + username: "Përdoruesi", + password: "Fjalëkalimi", + passwordHint: "Të paktën 8 karaktere.", + newPassword: "fjalëkalim i ri", + role: "Roli", + resetPassword: "Rivendos fjalëkalimin", + delete: "Fshi", + confirmDelete: "Të fshihet përdoruesi \"{{name}}\"?", + }, + roles: { + title: "Rolet", + add: "+ Shto rol", + new: "Rol i ri", + editTitle: "Ndrysho rolin", + name: "Emri", + permissions: "Lejet", + builtin: "i integruar", + edit: "Ndrysho", + delete: "Fshi", + confirmDelete: "Të fshihet roli \"{{name}}\"?", + permCount_one: "{{count}} leje", + permCount_other: "{{count}} leje", + userCount_one: "{{count}} përdorues", + userCount_other: "{{count}} përdorues", + }, shift: { label: "Turni:", open: "hapur", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 7b7795f..1ae3a39 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -9,8 +9,8 @@ import { import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import type { Lang, SessionUser } from "./api.js"; -import { closeShift, logout, openShift, setLanguagePref } from "./api.js"; +import type { Lang, Permission, SessionUser } from "./api.js"; +import { can, closeShift, logout, openShift, setLanguagePref } from "./api.js"; import { qk, queryClient } from "./lib/query.js"; import { setLanguage } from "./lib/i18n/index.js"; import { useLiveFeed } from "./lib/use-live-feed.js"; @@ -23,6 +23,8 @@ import { TariffComposer } from "./TariffComposer.js"; import { SubscriptionManager } from "./SubscriptionManager.js"; import { ShiftControl } from "./ShiftControl.js"; import { SiteSettings } from "./SiteSettings.js"; +import { UsersManager } from "./UsersManager.js"; +import { RolesManager } from "./RolesManager.js"; // Code-based TanStack Router (no file-based codegen — the app is small enough that // an explicit tree is clearer). The router context carries the signed-in user and @@ -154,7 +156,9 @@ function RootLayout() { const { t } = useTranslation(); // One app-wide WebSocket for the live feed (booth + any live widget). useLiveFeed(); - const isAdmin = user?.role === "admin"; + // Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants + // the permission its screen needs (the route guards enforce the same server-side). + const show = (perm: Permission) => can(user, perm); return (
@@ -163,17 +167,19 @@ function RootLayout() {
{user && } {user && } - {user?.username} · {user?.role} + {user?.username} · {user?.roleName}