feat(auth): dynamic RBAC — composable roles + resource×CRUD permissions
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -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}"`);
|
||||
}
|
||||
|
||||
+81
-8
@@ -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<Permission> = new Set(PERMISSIONS);
|
||||
const permsCache = new Map<string, ReadonlySet<Permission>>();
|
||||
|
||||
// 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<Permission> {
|
||||
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<void> {
|
||||
await req.jwtVerify();
|
||||
assertCsrf(req);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
// `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<void> {
|
||||
// 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)) {
|
||||
|
||||
@@ -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<void> {
|
||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
const guard = requirePermission("device:read");
|
||||
|
||||
app.get("/api/devices/status", { preHandler: guard }, async () => ({
|
||||
devices: monitor.snapshot(),
|
||||
|
||||
@@ -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<void> {
|
||||
// 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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
// 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" });
|
||||
|
||||
@@ -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<void> {
|
||||
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 () => ({
|
||||
|
||||
@@ -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<string>(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<Permission>();
|
||||
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<void> {
|
||||
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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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<void> {
|
||||
// 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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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));
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
|
||||
@@ -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<void> {
|
||||
// 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" });
|
||||
|
||||
@@ -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<void> {
|
||||
// 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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<FastifyInsta
|
||||
|
||||
const db = opts.db ?? createDb();
|
||||
|
||||
// Wire the RBAC permission resolver to this DB (route guards resolve a user's
|
||||
// role → permission set through it). See auth.ts.
|
||||
initAuth(db);
|
||||
|
||||
await app.register(cookie);
|
||||
|
||||
// WebSocket support for the live booth feed (/api/ws). Registered before the
|
||||
@@ -70,6 +76,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||
await authRoutes(app, db);
|
||||
|
||||
// RBAC administration: compose roles (role:*) + manage users (user:*). The
|
||||
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
|
||||
await userRoutes(app, db);
|
||||
await roleRoutes(app, db);
|
||||
|
||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiError,
|
||||
can,
|
||||
createRole,
|
||||
deleteRole,
|
||||
fetchRoles,
|
||||
updateRole,
|
||||
type ManagedRole,
|
||||
type Permission,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
|
||||
// Role management (admin). Compose a role from the permission grid (a checkbox
|
||||
// matrix of resource × action) and name it; users are then assigned a role. The
|
||||
// built-in `admin` role is shown read-only/locked (it always has every permission
|
||||
// and can't be edited or deleted). The server enforces the same. See
|
||||
// @parking/shared PERMISSIONS.
|
||||
|
||||
/** Group "resource:action" permissions by resource for the grid rows. */
|
||||
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
||||
const out: Record<string, Permission[]> = {};
|
||||
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<string | null>(null);
|
||||
const [editing, setEditing] = useState<ManagedRole | "new" | null>(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 (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("roles.title")}</h1>
|
||||
{canCreate && (
|
||||
<button type="button" onClick={() => { setEditing("new"); setError(null); }}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green">
|
||||
{t("roles.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
||||
|
||||
{editing && (
|
||||
<RoleEditor
|
||||
role={editing === "new" ? null : editing}
|
||||
grouped={grouped}
|
||||
onCancel={() => 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); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{roles.map((r) => (
|
||||
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-term-text">{r.name}</span>
|
||||
{r.builtin && (
|
||||
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-term-amber">
|
||||
{t("roles.builtin")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-term-muted">
|
||||
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canUpdate && !r.builtin && (
|
||||
<button type="button" onClick={() => { setEditing(r); setError(null); }}
|
||||
className="text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">{t("roles.edit")}</button>
|
||||
)}
|
||||
{canDelete && !r.builtin && (
|
||||
<button type="button"
|
||||
onClick={() => { if (confirm(t("roles.confirmDelete", { name: r.name }))) deleteRoleSafe(r.id, invalidate, onError); }}
|
||||
className="text-[11px] uppercase tracking-wider text-term-red hover:text-term-text">{t("roles.delete")}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, Permission[]>;
|
||||
onCancel: () => void;
|
||||
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(role?.name ?? "");
|
||||
const [perms, setPerms] = useState<Set<Permission>>(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 (
|
||||
<div className="mb-3 rounded-term border border-term-border bg-term-panel p-3">
|
||||
<div className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
||||
{role ? t("roles.editTitle") : t("roles.new")}
|
||||
</div>
|
||||
<label className="mb-3 block text-[11px] text-term-muted">
|
||||
{t("roles.name")}
|
||||
<input value={name} onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 w-64 rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
|
||||
</label>
|
||||
|
||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("roles.permissions")}</div>
|
||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||
{Object.entries(grouped).map(([resource, list]) => (
|
||||
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
||||
<span className="w-28 shrink-0 text-[12px] font-semibold text-term-text">{resource}</span>
|
||||
{list.map((p) => {
|
||||
const action = p.split(":")[1]!;
|
||||
return (
|
||||
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
|
||||
<input type="checkbox" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||||
{action}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel}
|
||||
className="rounded-term border border-term-border px-3 py-1 text-[12px] uppercase tracking-wider text-term-muted">{t("common.cancel")}</button>
|
||||
<button type="button" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green disabled:opacity-40">{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("users.title")}</h1>
|
||||
{canCreate && roles.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAdding(true); setError(null); }}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green"
|
||||
>
|
||||
{t("users.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
||||
|
||||
{adding && (
|
||||
<UserForm
|
||||
roles={roles}
|
||||
onCancel={() => setAdding(false)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
await createUser({ username: v.username, password: v.password, roleId: v.roleId });
|
||||
setAdding(false);
|
||||
invalidate();
|
||||
} catch (e) { onError(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-term border border-term-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.username")}</th>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.role")}</th>
|
||||
<th className="px-3 py-1.5 text-right">{t("common.none")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(usersQ.data?.users ?? []).map((u) => (
|
||||
<UserRow
|
||||
key={u.id}
|
||||
u={u}
|
||||
roles={roles}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{usersQ.data?.users.length === 0 && (
|
||||
<tr><td colSpan={3} className="px-3 py-3 text-term-muted">{t("users.none")}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<tr className="border-t border-term-border">
|
||||
<td className="px-3 py-1.5">{u.username}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{canUpdate ? (
|
||||
<select
|
||||
value={u.roleId}
|
||||
onChange={(e) => roleMut.mutate(e.target.value)}
|
||||
className="rounded-term border border-term-border bg-term-panel px-2 py-0.5 text-[12px]"
|
||||
>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
u.roleName
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{canUpdate && !resetting && (
|
||||
<button type="button" onClick={() => setResetting(true)}
|
||||
className="text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
{t("users.resetPassword")}
|
||||
</button>
|
||||
)}
|
||||
{canUpdate && resetting && (
|
||||
<span className="flex items-center gap-1">
|
||||
<input
|
||||
type="password" value={pw} autoFocus
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
<button type="button" disabled={pw.length < 8 || pwMut.isPending} onClick={() => pwMut.mutate()}
|
||||
className="text-[11px] uppercase tracking-wider text-term-green disabled:opacity-40">
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setResetting(false); setPw(""); }}
|
||||
className="text-[11px] uppercase tracking-wider text-term-muted">✕</button>
|
||||
</span>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (confirm(t("users.confirmDelete", { name: u.username }))) delMut.mutate(); }}
|
||||
className="text-[11px] uppercase tracking-wider text-term-red hover:text-term-text"
|
||||
>
|
||||
{t("users.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-3 rounded-term border border-term-border bg-term-panel p-3">
|
||||
<div className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber">{t("users.new")}</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<label className="text-[11px] text-term-muted">
|
||||
{t("users.username")}
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 w-full rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
|
||||
</label>
|
||||
<label className="text-[11px] text-term-muted">
|
||||
{t("users.password")}
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 w-full rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
|
||||
</label>
|
||||
<label className="text-[11px] text-term-muted">
|
||||
{t("users.role")}
|
||||
<select value={roleId} onChange={(e) => setRoleId(e.target.value)}
|
||||
className="mt-1 w-full rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text">
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-term-muted">{t("users.passwordHint")}</div>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel}
|
||||
className="rounded-term border border-term-border px-3 py-1 text-[12px] uppercase tracking-wider text-term-muted">{t("common.cancel")}</button>
|
||||
<button type="button" disabled={!valid} onClick={() => onSubmit({ username: username.trim(), password, roleId })}
|
||||
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green disabled:opacity-40">{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+60
-2
@@ -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<SessionUser> {
|
||||
return apiFetch<SessionUser>("/api/auth/login", {
|
||||
method: "POST",
|
||||
@@ -80,6 +90,54 @@ export async function fetchMe(): Promise<SessionUser | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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<ManagedUser> {
|
||||
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updateUser(id: string, body: { username?: string; roleId?: string }): Promise<ManagedUser> {
|
||||
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<ManagedRole> {
|
||||
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
|
||||
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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+51
-17
@@ -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 (
|
||||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||
@@ -163,17 +167,19 @@ function RootLayout() {
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shift" label={t("nav.shift")} />
|
||||
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
|
||||
{isAdmin && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
|
||||
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
||||
{show("site:update") && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
{show("tariff:read") && <NavLink to="/tariff" label={t("nav.tariff")} />}
|
||||
{show("subscription:read") && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
|
||||
{show("site:read") && <NavLink to="/site" label={t("nav.site")} />}
|
||||
{show("user:read") && <NavLink to="/users" label={t("nav.users")} />}
|
||||
{show("role:read") && <NavLink to="/roles" label={t("nav.roles")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
<span className="text-[11px] text-term-muted">
|
||||
{user?.username} · {user?.role}
|
||||
{user?.username} · {user?.roleName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -215,38 +221,64 @@ const shiftRoute = createRoute({
|
||||
path: "/shift",
|
||||
component: function ShiftRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <ShiftControl isAdmin={user?.role === "admin"} />;
|
||||
// "Admin" actions on the shift screen (drawer cash) need shift:cash.
|
||||
return <ShiftControl isAdmin={can(user, "shift:cash")} />;
|
||||
},
|
||||
});
|
||||
|
||||
/** Guard: admin-only routes redirect non-admins back to the booth. */
|
||||
function adminOnly(ctx: RouterContext) {
|
||||
if (ctx.user?.role !== "admin") throw redirect({ to: "/booth" });
|
||||
/** Guard factory: a route requiring `perm` redirects a user who lacks it back to
|
||||
* the booth. Same permission the server enforces — defence in depth, not the only
|
||||
* gate. */
|
||||
function requirePerm(perm: Permission) {
|
||||
return (ctx: RouterContext) => {
|
||||
if (!can(ctx.user, perm)) throw redirect({ to: "/booth" });
|
||||
};
|
||||
}
|
||||
|
||||
const setupRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/setup",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
beforeLoad: ({ context }) => requirePerm("site:update")(context),
|
||||
component: () => <SetupWizard />,
|
||||
});
|
||||
const tariffRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/tariff",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||
component: () => <TariffComposer />,
|
||||
});
|
||||
const subscriptionsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/subscriptions",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
||||
component: () => <SubscriptionManager />,
|
||||
});
|
||||
const siteRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/site",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
component: () => <SiteSettings canEdit={true} />,
|
||||
beforeLoad: ({ context }) => requirePerm("site:read")(context),
|
||||
component: function SiteRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <SiteSettings canEdit={can(user, "site:update")} />;
|
||||
},
|
||||
});
|
||||
const usersRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/users",
|
||||
beforeLoad: ({ context }) => requirePerm("user:read")(context),
|
||||
component: function UsersRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <UsersManager user={user} />;
|
||||
},
|
||||
});
|
||||
const rolesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/roles",
|
||||
beforeLoad: ({ context }) => requirePerm("role:read")(context),
|
||||
component: function RolesRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <RolesManager user={user} />;
|
||||
},
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
@@ -257,6 +289,8 @@ const routeTree = rootRoute.addChildren([
|
||||
tariffRoute,
|
||||
subscriptionsRoute,
|
||||
siteRoute,
|
||||
usersRoute,
|
||||
rolesRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
Reference in New Issue
Block a user