feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix", open-questions #16) — the grid stays the enforcement layer: - Move 1: each desk's money is guarded by that desk's own permissions. Manifest tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot touch the booth by construction. Replaces the session:read borrowing (tillPermission). /api/shift/tills lists the role's readable tills with canWork; history/movements without a till filter return the union of readable tills. - Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor, merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and "partial job" lints (warnings, never blocks). - Move 3: the live WebSocket admits any watch permission (event/session/device read or a module's feedPermission) and filters every push per role; report:read is the reports screen only. Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's role applies on the next request and a deleted user's session ends with 401. Tests: till guards + look-only role, feed rules, every job's permissions exist, role reassignment without re-login. 353/353. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
+36
-3
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { eq, rolePermissions, type Db } from "@parking/db";
|
import { eq, rolePermissions, users, type Db } from "@parking/db";
|
||||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||||
|
|
||||||
// Local JWT auth helpers — fully local, no external identity provider
|
// Local JWT auth helpers — fully local, no external identity provider
|
||||||
@@ -143,10 +143,41 @@ export function initAuth(db: Db): void {
|
|||||||
permsCache.clear();
|
permsCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the permission cache. Call after ANY write to roles / role_permissions
|
/** Clear the permission + role caches. Call after ANY write to roles / role_permissions
|
||||||
* (or a user's roleId) so the change takes effect on the next request. */
|
* or to a user's roleId / deletion, so the change takes effect on the next request. */
|
||||||
export function bumpPermsCache(): void {
|
export function bumpPermsCache(): void {
|
||||||
permsCache.clear();
|
permsCache.clear();
|
||||||
|
roleCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** userId → CURRENT roleId, cached until bumpPermsCache(). */
|
||||||
|
const roleCache = new Map<string, string | null>();
|
||||||
|
|
||||||
|
/** The user's CURRENT role. The token pins the roleId that was current at LOGIN; an
|
||||||
|
* admin reassigning a user's role (or deleting the user) must take effect on the next
|
||||||
|
* request exactly like editing a role does — otherwise the reassigned user keeps the
|
||||||
|
* old role's rights until they log out (found 2026-09-05: a user moved to a new
|
||||||
|
* wash role kept 403ing on the new role's permissions). null = the user is gone. */
|
||||||
|
export function currentRoleId(sub: string): string | null {
|
||||||
|
if (!authDb) throw new Error("auth not initialised (call initAuth)");
|
||||||
|
const hit = roleCache.get(sub);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const row = authDb
|
||||||
|
.select({ roleId: users.roleId, deletedAt: users.deletedAt })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, sub))
|
||||||
|
.get();
|
||||||
|
const roleId = row && row.deletedAt == null ? row.roleId : null;
|
||||||
|
roleCache.set(sub, roleId);
|
||||||
|
return roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After jwtVerify: replace the token's pinned roleId with the user's current one, or
|
||||||
|
* end the session if the user no longer exists. */
|
||||||
|
function refreshRole(req: FastifyRequest): void {
|
||||||
|
const roleId = currentRoleId(req.user.sub);
|
||||||
|
if (roleId === null) throw Object.assign(new Error("session no longer valid"), { statusCode: 401 });
|
||||||
|
if (roleId !== req.user.roleId) req.user.roleId = roleId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The permission set for a role id, cached. `admin` is always the full set. */
|
/** The permission set for a role id, cached. `admin` is always the full set. */
|
||||||
@@ -184,6 +215,7 @@ export function requirePermission(...required: Permission[]) {
|
|||||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||||
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||||
assertCsrf(req);
|
assertCsrf(req);
|
||||||
|
refreshRole(req);
|
||||||
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
@@ -201,4 +233,5 @@ export async function requireAuth(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await req.jwtVerify();
|
await req.jwtVerify();
|
||||||
assertCsrf(req);
|
assertCsrf(req);
|
||||||
|
refreshRole(req);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,3 +176,30 @@ describe("entitlement (vendor env)", () => {
|
|||||||
expect(cfg.json().modules).toEqual(["parking", "validation"]);
|
expect(cfg.json().modules).toEqual(["parking", "validation"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => {
|
||||||
|
const shared = await import("@parking/shared");
|
||||||
|
it("each till is guarded by its own module's permissions", () => {
|
||||||
|
expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" });
|
||||||
|
expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" });
|
||||||
|
const wash = new Set(["carwash:read", "carwash:cash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]);
|
||||||
|
// Module off → its till is not even addressable.
|
||||||
|
expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]);
|
||||||
|
});
|
||||||
|
it("the live feed admits by watch permission and filters ledger events by their module", () => {
|
||||||
|
expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual(
|
||||||
|
expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]),
|
||||||
|
);
|
||||||
|
expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read");
|
||||||
|
expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read");
|
||||||
|
expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read");
|
||||||
|
expect(shared.feedPermissionFor("payment")).toBe("event:read");
|
||||||
|
expect(shared.feedPermissionFor("validation")).toBe("event:read");
|
||||||
|
});
|
||||||
|
it("every job's permissions exist in the grid", () => {
|
||||||
|
for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,12 +5,21 @@ import {
|
|||||||
isModuleId,
|
isModuleId,
|
||||||
isTillId,
|
isTillId,
|
||||||
parseEntitledModules,
|
parseEntitledModules,
|
||||||
|
tillGuards,
|
||||||
tillsFor,
|
tillsFor,
|
||||||
tillsOf,
|
tillsOf,
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
|
type TillGuards,
|
||||||
type TillId,
|
type TillId,
|
||||||
} from "@parking/shared";
|
} from "@parking/shared";
|
||||||
import { roleHasPermissions } from "./auth.js";
|
import { requireAuth, roleHasPermissions } from "./auth.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
/** Set by requireTill(): the till this request addresses (already authorized). */
|
||||||
|
till?: TillId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
|
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
|
||||||
// @parking/shared; design in wiki/decisions/venue-modules.md).
|
// @parking/shared; design in wiki/decisions/venue-modules.md).
|
||||||
@@ -57,10 +66,36 @@ export function effectiveTillsFor(db: Db): TillId[] {
|
|||||||
return tillsOf(effectiveModulesFor(db));
|
return tillsOf(effectiveModulesFor(db));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tills a role may WORK here (open/close its shift, move its cash): effective
|
/** The tills a role may SEE (default) or WORK (`shift` / `cash`) here: the effective
|
||||||
* tills whose module permission the role holds. */
|
* tills whose module guard the role holds (each desk's money is guarded by that desk's
|
||||||
export function accessibleTillsFor(db: Db, roleId: string): TillId[] {
|
* own permissions — venue-modules.md §"Permissions matrix"). */
|
||||||
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]));
|
export function tillsReadableBy(db: Db, roleId: string, kind: keyof TillGuards = "read"): TillId[] {
|
||||||
|
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]), kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** preHandler factory for the shift/drawer routes: authenticate, parse the `till`
|
||||||
|
* (query on GET, body on POST; absent = booth; 400 `bad_till` when unknown or its
|
||||||
|
* module is off), then require the role to hold THAT TILL's guard for `kind` (403
|
||||||
|
* `till_forbidden`). The authorized till lands on `req.till`. The permission is thus
|
||||||
|
* resolved from the till, never fixed: the booth checks `shift:read`/`shift:create`/
|
||||||
|
* `drawer:create`, the wash `carwash:read`/`carwash:cash`. */
|
||||||
|
export function requireTill(db: Db, kind: keyof TillGuards, from: "query" | "body") {
|
||||||
|
return async (req: FastifyRequest, reply: FastifyReply): Promise<void | FastifyReply> => {
|
||||||
|
await requireAuth(req, reply);
|
||||||
|
const raw = from === "query" ? (req.query as { till?: unknown } | undefined)?.till : (req.body as { till?: unknown } | undefined)?.till;
|
||||||
|
const till = parseTill(db, raw);
|
||||||
|
if (!till) {
|
||||||
|
await reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
if (!roleHasPermissions(req.user.roleId, [tillGuards(till)[kind]])) {
|
||||||
|
await reply
|
||||||
|
.code(403)
|
||||||
|
.send({ error: `your role cannot ${kind === "read" ? "see" : "work"} the ${till} till`, code: "till_forbidden", till });
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
req.till = till;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
|
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
|
||||||
|
|||||||
@@ -422,15 +422,17 @@ describe("tills are gated by the module permission", () => {
|
|||||||
const a = await admin();
|
const a = await admin();
|
||||||
seedTariff(db);
|
seedTariff(db);
|
||||||
await seedSettings(a);
|
await seedSettings(a);
|
||||||
|
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
|
||||||
|
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
|
||||||
const washer = await seedUser(db, {
|
const washer = await seedUser(db, {
|
||||||
username: "lavazhier", roleId: "washer",
|
username: "lavazhier", roleId: "washer",
|
||||||
permissions: ["carwash:read", "carwash:create", "carwash:update", "shift:read", "shift:create", "drawer:create"],
|
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
|
||||||
});
|
});
|
||||||
const w = await login(app, washer.username, washer.password);
|
const w = await login(app, washer.username, washer.password);
|
||||||
// What the UI offers: only the wash till.
|
// What the UI offers: only the wash till.
|
||||||
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||||||
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||||||
// The booth's shift is refused outright (the role lacks session:read).
|
// The booth's shift is refused outright (the role holds no shift:*).
|
||||||
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
||||||
expect(booth.statusCode).toBe(403);
|
expect(booth.statusCode).toBe(403);
|
||||||
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
||||||
@@ -445,7 +447,15 @@ describe("tills are gated by the module permission", () => {
|
|||||||
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
||||||
expect(washCash.statusCode).toBe(200);
|
expect(washCash.statusCode).toBe(200);
|
||||||
|
|
||||||
// A booth operator (session:read, no carwash:read) cannot touch the wash till.
|
// A wash user who may look (carwash:read) but not work the till (no carwash:cash)
|
||||||
|
// sees the state and gets canWork=false; opening is refused.
|
||||||
|
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||||
|
const l = await login(app, looker.username, looker.password);
|
||||||
|
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
|
||||||
|
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
|
||||||
|
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
|
||||||
|
|
||||||
|
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
|
||||||
const booth1 = await seedUser(db, {
|
const booth1 = await seedUser(db, {
|
||||||
username: "boothie", roleId: "booth-op",
|
username: "boothie", roleId: "booth-op",
|
||||||
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
||||||
@@ -456,3 +466,29 @@ describe("tills are gated by the module permission", () => {
|
|||||||
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a role reassignment takes effect without re-login", () => {
|
||||||
|
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-R");
|
||||||
|
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||||
|
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
|
||||||
|
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
|
||||||
|
const l = await login(app, looker.username, looker.password);
|
||||||
|
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||||
|
expect(before.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
|
||||||
|
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
|
||||||
|
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
|
||||||
|
expect(moved.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Same cookie, no re-login: the token's pinned role is refreshed per request.
|
||||||
|
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||||
|
expect(after.statusCode).toBe(201);
|
||||||
|
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
|
||||||
|
expect(me.roleId).toBe("wash-op");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import type { TillId } from "@parking/shared";
|
||||||
import { accessibleTillsFor, parseTill } from "../modules.js";
|
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
|
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
|
||||||
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||||
|
|
||||||
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
||||||
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
||||||
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
||||||
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
|
// - POST /api/drawer/movement : operator records a cash_in/cash_out on a till.
|
||||||
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
// Guard = the till's `cash` (booth drawer:create,
|
||||||
// only their own; reviewers see all + can filter status.
|
// wash carwash:cash).
|
||||||
|
// - GET /api/drawer/movements: list with review status, over the tills the role may
|
||||||
|
// read (own movements); reviewers (drawer:review) see all
|
||||||
|
// tills + all operators and can filter status.
|
||||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||||
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
|
// - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read).
|
||||||
// payments + vouchers over the whole chain — the
|
|
||||||
// amount that carries across shifts).
|
|
||||||
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
||||||
// judgment about the operator settled outside the app, never a cash reversal.
|
// judgment about the operator settled outside the app, never a cash reversal.
|
||||||
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); the
|
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each
|
||||||
// balance and the list take a `till` filter. See wiki/concepts/shift.md "Tills".
|
// desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions
|
||||||
|
// matrix").
|
||||||
|
|
||||||
interface MovementBody {
|
interface MovementBody {
|
||||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||||
@@ -42,27 +45,19 @@ interface ReviewBody {
|
|||||||
interface MovementsQuery {
|
interface MovementsQuery {
|
||||||
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||||
status?: MovementStatus;
|
status?: MovementStatus;
|
||||||
/** Filter to one till; absent = every till. */
|
/** Filter to one till; absent = every till the role may read (reviewers: every till). */
|
||||||
till?: string;
|
till?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||||
const createGuard = requirePermission("drawer:create");
|
|
||||||
const reviewGuard = requirePermission("drawer:review");
|
const reviewGuard = requirePermission("drawer:review");
|
||||||
const readGuard = requirePermission("shift:read");
|
|
||||||
|
|
||||||
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
||||||
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
|
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, async (req, reply) => {
|
||||||
const b = req.body ?? ({} as MovementBody);
|
const b = req.body ?? ({} as MovementBody);
|
||||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||||
}
|
}
|
||||||
const till = parseTill(db, b.till);
|
|
||||||
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
|
||||||
// Moving a till's cash needs that till's module permission (see routes/shift.ts).
|
|
||||||
if (!accessibleTillsFor(db, req.user.roleId).includes(till)) {
|
|
||||||
return reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return await shift.recordVoucher({
|
return await shift.recordVoucher({
|
||||||
type: b.type,
|
type: b.type,
|
||||||
@@ -70,7 +65,7 @@ export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftSer
|
|||||||
amountMinor: b.amountMinor,
|
amountMinor: b.amountMinor,
|
||||||
reason: b.reason ?? "",
|
reason: b.reason ?? "",
|
||||||
currency: b.currency,
|
currency: b.currency,
|
||||||
till,
|
till: req.till!,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
@@ -78,29 +73,37 @@ export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftSer
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
// List movements + review status. Operators are hard-scoped to their OWN movements on
|
||||||
// reviewer sees ALL and may filter by status (the pending review queue).
|
// the tills they may read; a reviewer sees ALL and may filter by status (the pending
|
||||||
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req, reply) => {
|
// review queue).
|
||||||
|
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => {
|
||||||
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||||
|
const readable = tillsReadableBy(db, req.user.roleId);
|
||||||
|
if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||||
const q = req.query ?? {};
|
const q = req.query ?? {};
|
||||||
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||||
const till = q.till?.trim() ? parseTill(db, q.till.trim()) : undefined;
|
let tills: TillId[] | undefined = canReview ? undefined : readable;
|
||||||
if (till === null) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
if (q.till?.trim()) {
|
||||||
const movements = shift.movementsWithStatus({
|
const parsed = parseTill(db, q.till.trim());
|
||||||
operator: canReview ? undefined : req.user.username,
|
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
status,
|
if (!canReview && !readable.includes(parsed)) {
|
||||||
till,
|
return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed });
|
||||||
});
|
}
|
||||||
|
tills = [parsed];
|
||||||
|
}
|
||||||
|
const operator = canReview ? undefined : req.user.username;
|
||||||
|
const movements = tills
|
||||||
|
? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
||||||
|
: shift.movementsWithStatus({ operator, status });
|
||||||
return { movements, scope: canReview ? "all" : "self" };
|
return { movements, scope: canReview ? "all" : "self" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// A till's physical drawer balance now. Same visibility as the open shift's X-report
|
// A till's physical drawer balance now. Same visibility as the open shift's X-report
|
||||||
// (shift:read) — a drawer is a shared till, not per-operator data.
|
// (the till's read guard) — a drawer is a shared till, not per-operator data.
|
||||||
app.get<{ Querystring: { till?: string } }>("/api/drawer/balance", { preHandler: readGuard }, async (req, reply) => {
|
app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
|
||||||
const till = parseTill(db, req.query?.till);
|
till: req.till!,
|
||||||
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
...shift.drawerBalance(req.till!),
|
||||||
return { till, ...shift.drawerBalance(till) };
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
||||||
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { TillId } from "@parking/shared";
|
import { tillGuards, type TillId } from "@parking/shared";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import { requireAuth, roleHasPermissions } from "../auth.js";
|
||||||
import { accessibleTillsFor, effectiveTillsFor, parseTill } from "../modules.js";
|
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
|
||||||
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService, type ShiftSummary } from "../shift-service.js";
|
||||||
|
|
||||||
interface ShiftsQuery {
|
interface ShiftsQuery {
|
||||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||||
@@ -11,15 +11,7 @@ interface ShiftsQuery {
|
|||||||
/** ISO window over shift START time. */
|
/** ISO window over shift START time. */
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
/** Filter to one till; absent = every till. */
|
/** Filter to one till; absent = every till the role may read. */
|
||||||
till?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TillQuery {
|
|
||||||
/** Which till (default: the booth). */
|
|
||||||
till?: string;
|
|
||||||
}
|
|
||||||
interface TillBody {
|
|
||||||
till?: string;
|
till?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,25 +19,14 @@ interface TillBody {
|
|||||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
//
|
//
|
||||||
// TILLS: every endpoint takes a `till` (query on GET, body on POST; default booth).
|
// TILLS + PERMISSIONS: every endpoint addresses a `till` (query on GET, body on POST;
|
||||||
// A till is addressable only when the module that declares it is effective here
|
// default booth) and its guard is resolved FROM THE TILL (requireTill): the booth's shift
|
||||||
// (400 otherwise) — the wash desk's shift control passes till=carwash. WORKING a till
|
// is `shift:read` / `shift:create`, the wash's is `carwash:read` / `carwash:cash` — each
|
||||||
// (open/close, its state) additionally needs the role to hold that till's module
|
// desk's money is guarded by that desk's own permissions, so a wash role holds no
|
||||||
// permission (booth: session:read; carwash: carwash:read) — 403 `till_forbidden` — so a
|
// `shift:*` at all and cannot touch the booth. See venue-modules.md §"Permissions matrix".
|
||||||
// wash operator's role can never open the booth's shift, nor a booth operator the
|
|
||||||
// wash's. History (`/api/shifts`) stays scoped by shift:read/cash, not by till.
|
|
||||||
|
|
||||||
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||||
// Reading the shift state vs. opening/closing one's own shift.
|
const statusOf = (till: TillId, me: string, roleId: string) => {
|
||||||
const readGuard = requirePermission("shift:read");
|
|
||||||
const guard = requirePermission("shift:create");
|
|
||||||
|
|
||||||
const badTill = (reply: FastifyReply) => reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
|
||||||
const forbidden = (reply: FastifyReply, till: TillId) =>
|
|
||||||
reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
|
|
||||||
const mayWork = (roleId: string, till: TillId) => accessibleTillsFor(db, roleId).includes(till);
|
|
||||||
|
|
||||||
const statusOf = (till: TillId, me: string) => {
|
|
||||||
const open = shift.currentOpenShift(till);
|
const open = shift.currentOpenShift(till);
|
||||||
const heldBy = open?.identity ?? null;
|
const heldBy = open?.identity ?? null;
|
||||||
const drawer = shift.drawerBalance(till);
|
const drawer = shift.drawerBalance(till);
|
||||||
@@ -53,6 +34,8 @@ export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftServ
|
|||||||
till,
|
till,
|
||||||
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
||||||
isMine: open != null && heldBy === me,
|
isMine: open != null && heldBy === me,
|
||||||
|
/** May this role open/close this till's shift? (The UI offers the button only then.) */
|
||||||
|
canWork: roleHasPermissions(roleId, [tillGuards(till).shift]),
|
||||||
drawerMinor: drawer.balanceMinor,
|
drawerMinor: drawer.balanceMinor,
|
||||||
currency: drawer.currency,
|
currency: drawer.currency,
|
||||||
};
|
};
|
||||||
@@ -64,87 +47,88 @@ export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftServ
|
|||||||
// - till: which till this describes
|
// - till: which till this describes
|
||||||
// - open: the open shift { startedAt, operator } or null
|
// - open: the open shift { startedAt, operator } or null
|
||||||
// - isMine: true iff the open shift belongs to the requesting operator
|
// - isMine: true iff the open shift belongs to the requesting operator
|
||||||
|
// - canWork: may this role open/close it
|
||||||
// - operator: the requesting user (for the UI's own identity)
|
// - operator: the requesting user (for the UI's own identity)
|
||||||
// - tills: every till THIS ROLE may work (the booth + effective modules' tills it
|
// - tills: every till THIS ROLE may read — what the UI offers controls for
|
||||||
// holds the permission for) — what the UI offers controls for
|
app.get("/api/shift/current", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
|
||||||
app.get<{ Querystring: TillQuery }>("/api/shift/current", { preHandler: readGuard }, async (req, reply) => {
|
operator: req.user.username,
|
||||||
const till = parseTill(db, req.query?.till);
|
tills: tillsReadableBy(db, req.user.roleId),
|
||||||
if (!till) return badTill(reply);
|
...statusOf(req.till!, req.user.username, req.user.roleId),
|
||||||
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
|
}));
|
||||||
return { operator: req.user.username, tills: accessibleTillsFor(db, req.user.roleId), ...statusOf(till, req.user.username) };
|
|
||||||
});
|
|
||||||
|
|
||||||
// The state of every till this role may work, in one read — the shift hub lists
|
// The state of every till this role may read, in one read — the shift hub lists
|
||||||
// each open shift and offers "start" for the idle ones.
|
// each open shift and offers "start" for the idle ones it may work.
|
||||||
app.get("/api/shift/tills", { preHandler: readGuard }, async (req) => {
|
app.get("/api/shift/tills", { preHandler: requireAuth }, async (req) => {
|
||||||
const me = req.user.username;
|
const me = req.user.username;
|
||||||
return { operator: me, tills: accessibleTillsFor(db, req.user.roleId).map((t) => statusOf(t, me)) };
|
return { operator: me, tills: tillsReadableBy(db, req.user.roleId).map((t) => statusOf(t, me, req.user.roleId)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
||||||
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
||||||
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
||||||
// (the Z-report at close is the signed record). 204 when no shift is open.
|
// (the Z-report at close is the signed record). 204 when no shift is open.
|
||||||
app.get<{ Querystring: TillQuery }>("/api/shift/report", { preHandler: readGuard }, async (req, reply) => {
|
app.get("/api/shift/report", { preHandler: requireTill(db, "read", "query") }, async (req, reply) => {
|
||||||
const till = parseTill(db, req.query?.till);
|
const report = shift.currentReport(req.till!);
|
||||||
if (!till) return badTill(reply);
|
|
||||||
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
|
|
||||||
const report = shift.currentReport(till);
|
|
||||||
if (!report) return reply.code(204).send();
|
if (!report) return reply.code(204).send();
|
||||||
return report;
|
return report;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Completed shift history. SCOPED by permission:
|
// Completed shift history. SCOPED by permission:
|
||||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
// - a till's `read` guard (operators) → own shifts only, on the tills they may read;
|
||||||
|
// operator/from/to params ignored.
|
||||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||||
// `operator` and a `from`/`to` time window over each shift's START.
|
// `operator` and a `from`/`to` time window over each shift's START.
|
||||||
// This keeps one operator from reading another's takings while letting admins
|
// This keeps one operator from reading another's takings while letting admins
|
||||||
// reconcile across the site. The data is the signed shift_z_report chain. Both
|
// reconcile across the site. The data is the signed shift_z_report chain. Both
|
||||||
// scopes may filter by `till`.
|
// scopes may filter by `till` (must be one the role may read).
|
||||||
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req, reply) => {
|
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: requireAuth }, async (req, reply) => {
|
||||||
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
||||||
|
const readable = tillsReadableBy(db, req.user.roleId);
|
||||||
|
if (!canSeeAll && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||||
const q = req.query ?? {};
|
const q = req.query ?? {};
|
||||||
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
||||||
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
||||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||||
let till: TillId | undefined;
|
let tills: TillId[] = canSeeAll ? [] : readable; // [] = no till filter (admin)
|
||||||
if (q.till?.trim()) {
|
if (q.till?.trim()) {
|
||||||
const parsed = parseTill(db, q.till.trim());
|
const parsed = parseTill(db, q.till.trim());
|
||||||
if (!parsed) return badTill(reply);
|
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
till = parsed;
|
if (!canSeeAll && !readable.includes(parsed)) return badTill(reply, parsed);
|
||||||
|
tills = [parsed];
|
||||||
}
|
}
|
||||||
const shifts = shift.listShifts({ operator, from, to, till });
|
const shifts: ShiftSummary[] =
|
||||||
|
tills.length === 0
|
||||||
|
? shift.listShifts({ operator, from, to })
|
||||||
|
: tills.flatMap((till) => shift.listShifts({ operator, from, to, till })).sort((a, b) => b.index - a.index);
|
||||||
// Admins also get the distinct operator list (unfiltered) for the filter
|
// Admins also get the distinct operator list (unfiltered) for the filter
|
||||||
// dropdown — operators don't see other names, so it's scope-gated.
|
// dropdown — operators don't see other names, so it's scope-gated.
|
||||||
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: effectiveTillsFor(db) };
|
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: tillsReadableBy(db, req.user.roleId) };
|
||||||
return { shifts, scope: "self", tills: effectiveTillsFor(db) };
|
return { shifts, scope: "self", tills: readable };
|
||||||
});
|
});
|
||||||
|
|
||||||
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||||
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||||
|
|
||||||
app.post<{ Body: TillBody }>("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/open", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
|
||||||
const till = parseTill(db, req.body?.till);
|
|
||||||
if (!till) return badTill(reply);
|
|
||||||
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
|
|
||||||
try {
|
try {
|
||||||
return await shift.open(req.user.username, till);
|
return await shift.open(req.user.username, req.till!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
||||||
return reply.code(500).send({ error: (err as Error).message });
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Body: TillBody }>("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/close", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
|
||||||
const till = parseTill(db, req.body?.till);
|
|
||||||
if (!till) return badTill(reply);
|
|
||||||
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
|
|
||||||
try {
|
try {
|
||||||
return await shift.close(req.user.username, till);
|
return await shift.close(req.user.username, req.till!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
||||||
return reply.code(500).send({ error: (err as Error).message });
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function badTill(reply: FastifyReply, till: TillId): FastifyReply {
|
||||||
|
return reply.code(403).send({ error: `your role cannot see the ${till} till`, code: "till_forbidden", till });
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import bcrypt from "bcrypt";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
||||||
import { ADMIN_ROLE_ID } from "@parking/shared";
|
import { ADMIN_ROLE_ID } from "@parking/shared";
|
||||||
import { permissionsFor, requirePermission } from "../auth.js";
|
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||||
import { softDelete } from "../recycle-bin.js";
|
import { softDelete } from "../recycle-bin.js";
|
||||||
|
|
||||||
// User management (admin). Users are created/edited at runtime here — the
|
// User management (admin). Users are created/edited at runtime here — the
|
||||||
@@ -202,6 +202,8 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return reply.code(400).send({ error: "nothing to update" });
|
return reply.code(400).send({ error: "nothing to update" });
|
||||||
}
|
}
|
||||||
db.update(users).set(next).where(eq(users.id, id)).run();
|
db.update(users).set(next).where(eq(users.id, id)).run();
|
||||||
|
// A role reassignment takes effect on the user's NEXT request (auth.ts refreshRole).
|
||||||
|
if (next.roleId) bumpPermsCache();
|
||||||
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -251,6 +253,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return reply.code(409).send({ error: "cannot delete the last admin" });
|
return reply.code(409).send({ error: "cannot delete the last admin" });
|
||||||
}
|
}
|
||||||
softDelete(db, "user", id, req.user.sub);
|
softDelete(db, "user", id, req.user.sub);
|
||||||
|
bumpPermsCache(); // their live session ends on its next request
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import { feedPermissionFor, watchPermissions, type LedgerEvent, type Permission } from "@parking/shared";
|
||||||
import { requireAuth, roleHasPermissions } from "../auth.js";
|
import { currentRoleId, requireAuth, roleHasPermissions } from "../auth.js";
|
||||||
|
import { effectiveModulesFor } from "../modules.js";
|
||||||
import {
|
import {
|
||||||
deviceEvents,
|
deviceEvents,
|
||||||
type LaneStatusEvent,
|
type LaneStatusEvent,
|
||||||
@@ -45,9 +46,14 @@ import { getOccupancy } from "../occupancy.js";
|
|||||||
// headers on a WebSocket, so this path is unreachable from a browser and adds
|
// headers on a WebSocket, so this path is unreachable from a browser and adds
|
||||||
// no CSWSH surface; the Origin allowlist still applies to both paths.
|
// no CSWSH surface; the Origin allowlist still applies to both paths.
|
||||||
|
|
||||||
/** Permission required to watch the live feed (a read-only stream of ledger +
|
// WHO may watch, and WHAT they see (venue-modules.md §"Permissions matrix", move 3):
|
||||||
* device status). Any role granted `report:read` may watch. */
|
// a role connects if it holds ANY watch permission — the core feed/occupancy/device
|
||||||
const WATCH_PERMISSION = "report:read" as const;
|
// ones or an effective module's own (carwash:read) — and every pushed message is then
|
||||||
|
// FILTERED per role: a ledger event needs feedPermissionFor(type) (the owning module's,
|
||||||
|
// else event:read); occupancy + the plate backfill need session:read; device / printer /
|
||||||
|
// lane / radar need device:read. `report:read` is the REPORTS screen, not the socket: the
|
||||||
|
// wash desk gets a live queue without the booth's ledger, the booth a feed without reports.
|
||||||
|
type Viewer = { has: (p: Permission) => boolean };
|
||||||
|
|
||||||
/** Handshake header carrying a desktop WS ticket (see file header). */
|
/** Handshake header carrying a desktop WS ticket (see file header). */
|
||||||
const WS_TICKET_HEADER = "x-ws-ticket";
|
const WS_TICKET_HEADER = "x-ws-ticket";
|
||||||
@@ -108,18 +114,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
|||||||
type OutMsg =
|
type OutMsg =
|
||||||
| {
|
| {
|
||||||
kind: "hello";
|
kind: "hello";
|
||||||
occupancy: ReturnType<typeof getOccupancy>;
|
occupancy: ReturnType<typeof getOccupancy> | null;
|
||||||
devices: unknown;
|
devices: unknown;
|
||||||
lanes: LaneStatusEvent;
|
lanes: LaneStatusEvent | null;
|
||||||
radar: LanePresenceEvent;
|
radar: LanePresenceEvent | null;
|
||||||
}
|
}
|
||||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> | null }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: unknown }
|
| { kind: "device-status"; event: unknown }
|
||||||
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||||
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
||||||
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
/** The role the WS preHandler authenticated (ticket or cookie path) — for the handler's filter. */
|
||||||
|
wsRoleId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -161,14 +174,18 @@ export async function wsRoutes(
|
|||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
roleId = req.user.roleId;
|
roleId = currentRoleId(req.user.sub) ?? "";
|
||||||
}
|
|
||||||
if (!roleHasPermissions(roleId, [WATCH_PERMISSION])) {
|
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
|
||||||
}
|
}
|
||||||
|
const may = watchPermissions(effectiveModulesFor(db)).some((p) => roleHasPermissions(roleId, [p]));
|
||||||
|
if (!may) throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
req.wsRoleId = roleId;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
(socket) => {
|
(socket, req) => {
|
||||||
|
const roleId = req.wsRoleId ?? req.user?.roleId ?? "";
|
||||||
|
const viewer: Viewer = { has: (p) => roleHasPermissions(roleId, [p]) };
|
||||||
|
const seesOccupancy = viewer.has("session:read");
|
||||||
|
const seesDevices = viewer.has("device:read");
|
||||||
const send = (msg: OutMsg) => {
|
const send = (msg: OutMsg) => {
|
||||||
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
||||||
if (socket.readyState === 1) {
|
if (socket.readyState === 1) {
|
||||||
@@ -182,40 +199,43 @@ export async function wsRoutes(
|
|||||||
|
|
||||||
// Initial snapshot so the client renders immediately, before any event:
|
// Initial snapshot so the client renders immediately, before any event:
|
||||||
// occupancy AND the current device-status set (for the footer).
|
// occupancy AND the current device-status set (for the footer).
|
||||||
|
// Each part of the snapshot only for a role that may see it (null otherwise).
|
||||||
send({
|
send({
|
||||||
kind: "hello",
|
kind: "hello",
|
||||||
occupancy: getOccupancy(db),
|
occupancy: seesOccupancy ? getOccupancy(db) : null,
|
||||||
devices: deviceMonitor.snapshot(),
|
devices: seesDevices ? deviceMonitor.snapshot() : null,
|
||||||
lanes: laneStatus.snapshot(),
|
lanes: seesDevices ? laneStatus.snapshot() : null,
|
||||||
radar: lanePresence.snapshot(),
|
radar: seesDevices ? lanePresence.snapshot() : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
const offLedger = deviceEvents.onLedger((event) => {
|
const offLedger = deviceEvents.onLedger((event) => {
|
||||||
|
// Per-role filter: the event type's feed permission (module's own, else event:read).
|
||||||
|
if (!viewer.has(feedPermissionFor((event as { type: LedgerEvent["type"] }).type))) return;
|
||||||
// Enrich with read-time display fields (subscriber name) before fan-out.
|
// Enrich with read-time display fields (subscriber name) before fan-out.
|
||||||
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
|
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
|
||||||
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
|
send({ kind: "ledger", event: enriched, occupancy: seesOccupancy ? getOccupancy(db) : null });
|
||||||
});
|
});
|
||||||
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
||||||
send({ kind: "printer-status", event });
|
if (seesDevices) send({ kind: "printer-status", event });
|
||||||
});
|
});
|
||||||
// Unified device status (all categories) for the booth footer — pushed on
|
// Unified device status (all categories) for the booth footer — pushed on
|
||||||
// change; the initial set rode the hello above.
|
// change; the initial set rode the hello above.
|
||||||
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
||||||
send({ kind: "device-status", event });
|
if (seesDevices) send({ kind: "device-status", event });
|
||||||
});
|
});
|
||||||
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
|
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
|
||||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||||
send({ kind: "lane-status", lanes });
|
if (seesDevices) send({ kind: "lane-status", lanes });
|
||||||
});
|
});
|
||||||
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||||
const offPresence = deviceEvents.onLanePresence((radar) => {
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
send({ kind: "lane-presence", radar });
|
if (seesDevices) send({ kind: "lane-presence", radar });
|
||||||
});
|
});
|
||||||
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
||||||
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
||||||
send({ kind: "plate-recognized", plate });
|
if (seesOccupancy) send({ kind: "plate-recognized", plate });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ import {
|
|||||||
fetchEvents,
|
fetchEvents,
|
||||||
fetchShift,
|
fetchShift,
|
||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
|
fetchShiftTills,
|
||||||
fetchShifts,
|
fetchShifts,
|
||||||
recordDrawerMovement,
|
recordDrawerMovement,
|
||||||
reviewDrawerMovement,
|
reviewDrawerMovement,
|
||||||
type DrawerMovement,
|
type DrawerMovement,
|
||||||
type MovementStatus,
|
type MovementStatus,
|
||||||
|
type SessionUser,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
type TillId,
|
type TillId,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { shiftKey } from "./lib/use-shift.js";
|
import { shiftKey } from "./lib/use-shift.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import { tillOf, type LedgerEvent } from "@parking/shared";
|
import { tillGuards, tillOf, type LedgerEvent } from "@parking/shared";
|
||||||
|
import { can } from "./api.js";
|
||||||
|
|
||||||
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
||||||
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
||||||
@@ -53,14 +56,17 @@ function StatusBadge({ status }: { status: MovementStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
export function DrawerManager({ user, canReview }: { user: SessionUser | null; canReview: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [till, setTill] = useState<TillId>("booth");
|
// Which tills this role may READ (each desk's drawer is guarded by that desk's own
|
||||||
// Which tills exist here (the booth + effective money-taking modules') — from the
|
// permissions) — the first one is the default view.
|
||||||
// booth's status read, which every till answer carries.
|
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
|
||||||
const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") });
|
const tills: TillId[] = status.data?.tills.map((x) => x.till) ?? [];
|
||||||
const tills = status.data?.tills ?? ["booth"];
|
const [chosen, setChosen] = useState<TillId | null>(null);
|
||||||
|
const till = chosen && tills.includes(chosen) ? chosen : (tills[0] ?? "booth");
|
||||||
|
// Recording on a till needs that till's `cash` guard (booth drawer:create, wash carwash:cash).
|
||||||
|
const canCreate = can(user, tillGuards(till).cash);
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||||
// A voucher moves the open shift's added/removed figures too (the X-report).
|
// A voucher moves the open shift's added/removed figures too (the X-report).
|
||||||
@@ -73,7 +79,7 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
{tills.length > 1 && (
|
{tills.length > 1 && (
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{tills.map((x) => (
|
{tills.map((x) => (
|
||||||
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setTill(x)}>
|
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setChosen(x)}>
|
||||||
{t(`till.${x}Long`)}
|
{t(`till.${x}Long`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ import {
|
|||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { MODULES, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared";
|
||||||
|
|
||||||
// Role management (admin). Compose a role from the permission grid (a checkbox
|
// 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
|
// 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
|
// 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
|
// and can't be edited or deleted). The server enforces the same. See
|
||||||
// @parking/shared PERMISSIONS.
|
// @parking/shared PERMISSIONS.
|
||||||
|
//
|
||||||
|
// JOBS (venue-modules.md §"Permissions matrix", move 2): each EFFECTIVE module brings
|
||||||
|
// named permission bundles ("Booth operator", "Wash operator", "Merchant") offered as
|
||||||
|
// one-click chips above the grid — a chip adds/removes its bundle; the grid stays the
|
||||||
|
// fine-tune + enforcement layer. The editor LINTS the result (warnings, never blocks):
|
||||||
|
// "mixes desks" (may open more than one till) and "partial job" (holds a module's read
|
||||||
|
// permission but not the rest of its job — a desk that can look but not act).
|
||||||
|
|
||||||
/** Group "resource:action" permissions by resource for the grid rows. */
|
/** Group "resource:action" permissions by resource for the grid rows. */
|
||||||
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
||||||
@@ -75,6 +83,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
|||||||
<RoleEditor
|
<RoleEditor
|
||||||
role={editing === "new" ? null : editing}
|
role={editing === "new" ? null : editing}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
|
effective={(user?.modules ?? []) as ModuleId[]}
|
||||||
onCancel={() => setEditing(null)}
|
onCancel={() => setEditing(null)}
|
||||||
onSubmit={async (v) => {
|
onSubmit={async (v) => {
|
||||||
try {
|
try {
|
||||||
@@ -124,11 +133,37 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown)
|
|||||||
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
|
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The jobs the composer offers: every effective module's, in registry order. */
|
||||||
|
function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] {
|
||||||
|
return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composer lints — warnings about what the admin just composed. */
|
||||||
|
function lintRole(perms: Set<Permission>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
|
||||||
|
const has = (p: Permission) => perms.has(p);
|
||||||
|
const out: { key: string; vars?: Record<string, string> }[] = [];
|
||||||
|
// Mixes desks: may OPEN more than one till.
|
||||||
|
const workable: TillId[] = tillsFor(effective, has, "shift");
|
||||||
|
if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } });
|
||||||
|
// Partial job: holds a module's till-read (or a job's first permission) but not the
|
||||||
|
// rest of that job's OWN-resource permissions (a booth job also carries core
|
||||||
|
// permissions a supervisor legitimately leaves out — those don't count).
|
||||||
|
for (const { module, job } of jobsFor(effective)) {
|
||||||
|
const m = MODULES.find((x) => x.id === module)!;
|
||||||
|
const anchor = m.tillGuards?.read ?? job.permissions[0];
|
||||||
|
if (!anchor || !has(anchor)) continue;
|
||||||
|
const own = job.permissions.filter((p) => !has(p) && m.resources.some((r) => p.startsWith(`${r}:`)));
|
||||||
|
if (own.length > 0) out.push({ key: "roles.lintPartialJob", vars: { job: job.id, missing: own.join(", ") } });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function RoleEditor({
|
function RoleEditor({
|
||||||
role, grouped, onCancel, onSubmit,
|
role, grouped, effective, onCancel, onSubmit,
|
||||||
}: {
|
}: {
|
||||||
role: ManagedRole | null;
|
role: ManagedRole | null;
|
||||||
grouped: Record<string, Permission[]>;
|
grouped: Record<string, Permission[]>;
|
||||||
|
effective: readonly ModuleId[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -141,6 +176,16 @@ function RoleEditor({
|
|||||||
next.has(p) ? next.delete(p) : next.add(p);
|
next.has(p) ? next.delete(p) : next.add(p);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
const jobs = useMemo(() => jobsFor(effective), [effective]);
|
||||||
|
const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
|
||||||
|
const toggleJob = (job: JobPreset) =>
|
||||||
|
setPerms((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (job.permissions.every((p) => prev.has(p))) for (const p of job.permissions) next.delete(p);
|
||||||
|
else for (const p of job.permissions) next.add(p);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const lints = useMemo(() => lintRole(perms, effective), [perms, effective]);
|
||||||
|
|
||||||
const valid = name.trim().length > 0;
|
const valid = name.trim().length > 0;
|
||||||
|
|
||||||
@@ -151,6 +196,34 @@ function RoleEditor({
|
|||||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="label">{t("roles.jobs")}</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{jobs.map(({ module, job }) => (
|
||||||
|
<button
|
||||||
|
key={job.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${jobOn(job) ? "btn-primary" : ""}`}
|
||||||
|
title={job.permissions.join(", ")}
|
||||||
|
onClick={() => toggleJob(job)}
|
||||||
|
>
|
||||||
|
{t(`jobs.${job.id}`)} <span className="opacity-60">· {t(`modules.name.${module}`)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="hint">{t("roles.jobsHint")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lints.length > 0 && (
|
||||||
|
<div className="mb-3 rounded-term border border-term-amber/60 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||||
|
{lints.map((l) => (
|
||||||
|
<div key={l.key + JSON.stringify(l.vars)}>{t(l.key, l.vars)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="label">{t("roles.permissions")}</div>
|
<div className="label">{t("roles.permissions")}</div>
|
||||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||||
{Object.entries(grouped).map(([resource, list]) => (
|
{Object.entries(grouped).map(([resource, list]) => (
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import { Spinner } from "./ui/Spinner.js";
|
|||||||
export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift(till);
|
const { status, isOpen, isMine, blockedByOther, heldBy } = useShift(till);
|
||||||
|
// The till's `shift` guard (booth shift:create / wash carwash:cash). A role that may
|
||||||
|
// only LOOK sees the state text, never the button; the server refuses the same.
|
||||||
|
const canWork = status?.canWork ?? false;
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
// Closing a shift signs the Z-report and is irreversible, so the button never
|
// Closing a shift signs the Z-report and is irreversible, so the button never
|
||||||
@@ -78,6 +81,7 @@ export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{canWork && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || blockedByOther}
|
disabled={busy || blockedByOther}
|
||||||
@@ -93,6 +97,12 @@ export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
|||||||
label
|
label
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{!canWork && isOpen && (
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
|
{till === "booth" ? t("shift.headerHeldByShort", { operator: heldBy ?? "?" }) : t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||||
{till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })}
|
{till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ type CurrentShift = ShiftSummary & { open: true; isMine: boolean };
|
|||||||
* X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open`
|
* X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open`
|
||||||
* marks it for the badge + the action pane. Also returns every till the site has, so
|
* marks it for the badge + the action pane. Also returns every till the site has, so
|
||||||
* the hub can offer "start shift" per till and show badges only when there are two. */
|
* the hub can offer "start shift" per till and show badges only when there are two. */
|
||||||
function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch: () => void } {
|
function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workable: TillId[]; refetch: () => void } {
|
||||||
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
|
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
|
||||||
const openTills = (status.data?.tills ?? []).filter((t) => t.open != null);
|
const openTills = (status.data?.tills ?? []).filter((t) => t.open != null);
|
||||||
// One X-report per open till (the key carries the till list so a newly opened
|
// One X-report per open till (the key carries the till list so a newly opened
|
||||||
@@ -70,7 +70,8 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch
|
|||||||
void status.refetch();
|
void status.refetch();
|
||||||
void reports.refetch();
|
void reports.refetch();
|
||||||
};
|
};
|
||||||
const tills = status.data?.tills.map((t) => t.till) ?? ["booth"];
|
const tills = status.data?.tills.map((t) => t.till) ?? [];
|
||||||
|
const workable = status.data?.tills.filter((t) => t.canWork).map((t) => t.till) ?? [];
|
||||||
const current: CurrentShift[] = [];
|
const current: CurrentShift[] = [];
|
||||||
openTills.forEach((t, i) => {
|
openTills.forEach((t, i) => {
|
||||||
const x = reports.data?.[i];
|
const x = reports.data?.[i];
|
||||||
@@ -98,7 +99,7 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch
|
|||||||
isMine: t.isMine,
|
isMine: t.isMine,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return { current, tills, refetch };
|
return { current, tills, workable, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
||||||
@@ -110,7 +111,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [tillFilter, setTillFilter] = useState<TillId | "">("");
|
const [tillFilter, setTillFilter] = useState<TillId | "">("");
|
||||||
|
|
||||||
const { current, tills, refetch: refetchCurrent } = useCurrentShifts();
|
const { current, tills, workable, refetch: refetchCurrent } = useCurrentShifts();
|
||||||
const multiTill = tills.length > 1;
|
const multiTill = tills.length > 1;
|
||||||
|
|
||||||
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
||||||
@@ -147,9 +148,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [q.data, currentIds]);
|
}, [q.data, currentIds]);
|
||||||
|
|
||||||
// Tills with no open shift → offer "start" for each (gated on shift:create).
|
// Tills this role may WORK with no open shift → offer "start" for each.
|
||||||
const openOn = new Set(current.map((c) => c.till));
|
const openOn = new Set(current.map((c) => c.till));
|
||||||
const startable = tills.filter((x) => !openOn.has(x));
|
const startable = workable.filter((x) => !openOn.has(x));
|
||||||
|
|
||||||
function refreshAll() {
|
function refreshAll() {
|
||||||
void q.refetch();
|
void q.refetch();
|
||||||
|
|||||||
@@ -1075,6 +1075,8 @@ export interface TillShiftStatus {
|
|||||||
open: { startedAt: string; operator: string | null } | null;
|
open: { startedAt: string; operator: string | null } | null;
|
||||||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
|
/** May this role open/close this till's shift (its module's `shift` guard)? */
|
||||||
|
canWork: boolean;
|
||||||
/** Live physical drawer balance of this till (cash payments + cash movements). */
|
/** Live physical drawer balance of this till (cash payments + cash movements). */
|
||||||
drawerMinor: number;
|
drawerMinor: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
|
|||||||
@@ -911,6 +911,17 @@ export const en: Catalog = {
|
|||||||
permCount_other: "{{count}} permissions",
|
permCount_other: "{{count}} permissions",
|
||||||
userCount_one: "{{count}} user",
|
userCount_one: "{{count}} user",
|
||||||
userCount_other: "{{count}} users",
|
userCount_other: "{{count}} users",
|
||||||
|
// Jobs — one-click permission bundles each module brings; the grid stays the fine-tune.
|
||||||
|
jobs: "Jobs",
|
||||||
|
jobsHint: "A job adds its permissions in one click; fine-tune below. Tap it again to remove them.",
|
||||||
|
lintMixedTills: "This role can open more than one till ({{tills}}) — one person, two drawers. Intended?",
|
||||||
|
lintPartialJob: "Partial \"{{job}}\": missing {{missing}} — this desk can look but not act.",
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
"booth-operator": "Booth operator",
|
||||||
|
"booth-supervisor": "Booth supervisor",
|
||||||
|
merchant: "Merchant (validation)",
|
||||||
|
"wash-operator": "Wash operator",
|
||||||
},
|
},
|
||||||
shift: {
|
shift: {
|
||||||
label: "Shift:",
|
label: "Shift:",
|
||||||
|
|||||||
@@ -925,6 +925,17 @@ export const sq = {
|
|||||||
permCount_other: "{{count}} leje",
|
permCount_other: "{{count}} leje",
|
||||||
userCount_one: "{{count}} përdorues",
|
userCount_one: "{{count}} përdorues",
|
||||||
userCount_other: "{{count}} përdorues",
|
userCount_other: "{{count}} përdorues",
|
||||||
|
// Punët — pako lejesh që sjell çdo modul; rrjeta poshtë mbetet për rregullim të imët.
|
||||||
|
jobs: "Punët",
|
||||||
|
jobsHint: "Një punë shton lejet e saj me një klik; rregulloji poshtë. Kliko sërish për t'i hequr.",
|
||||||
|
lintMixedTills: "Ky rol mund të hapë më shumë se një arkë ({{tills}}) — një person, dy arka. E qëllimshme?",
|
||||||
|
lintPartialJob: "\"{{job}}\" e pjesshme: mungojnë {{missing}} — kjo tavolinë sheh, por nuk vepron.",
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
"booth-operator": "Operator kabine",
|
||||||
|
"booth-supervisor": "Përgjegjës kabine",
|
||||||
|
merchant: "Tregtar (validime)",
|
||||||
|
"wash-operator": "Operator lavazhi",
|
||||||
},
|
},
|
||||||
shift: {
|
shift: {
|
||||||
label: "Turni:",
|
label: "Turni:",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AnyRoute } from "@tanstack/react-router";
|
import type { AnyRoute } from "@tanstack/react-router";
|
||||||
import type { ModuleId } from "@parking/shared";
|
import { watchPermissions, type ModuleId } from "@parking/shared";
|
||||||
import type { Permission, SessionUser } from "../api.js";
|
import { can, type Permission, type SessionUser } from "../api.js";
|
||||||
import type { rootRoute } from "../router.js";
|
import type { rootRoute } from "../router.js";
|
||||||
|
|
||||||
/** The app's root route (type only — a runtime import here would be a cycle). */
|
/** The app's root route (type only — a runtime import here would be a cycle). */
|
||||||
@@ -19,6 +19,15 @@ export function moduleOn(user: SessionUser | null, id: ModuleId): boolean {
|
|||||||
return !!user && Array.isArray(user.modules) && user.modules.includes(id);
|
return !!user && Array.isArray(user.modules) && user.modules.includes(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** May this role open the live WebSocket at all? Any watch permission (core event/
|
||||||
|
* session/device read, or an effective module's own feed permission). The server
|
||||||
|
* admits by the same rule and then filters what it pushes. NOT report:read. */
|
||||||
|
export function canWatchFeed(user: SessionUser | null): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
const effective = Array.isArray(user.modules) ? user.modules : [];
|
||||||
|
return watchPermissions(effective).some((p) => can(user, p));
|
||||||
|
}
|
||||||
|
|
||||||
export interface WebModuleNav {
|
export interface WebModuleNav {
|
||||||
to: string;
|
to: string;
|
||||||
/** i18n key for the header label. */
|
/** i18n key for the header label. */
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js";
|
|||||||
|
|
||||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
// Parts a role may not see arrive as null (the server filters per role — ws.ts).
|
||||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
| { kind: "hello"; occupancy: Occupancy | null; devices: DeviceStatus[] | null; lanes: LaneStatus | null; radar: LanePresence | null }
|
||||||
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy | null }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus }
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
| { kind: "lane-status"; lanes: LaneStatus }
|
| { kind: "lane-status"; lanes: LaneStatus }
|
||||||
@@ -67,7 +68,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
return; // ignore malformed frames
|
return; // ignore malformed frames
|
||||||
}
|
}
|
||||||
if (msg.kind === "hello") {
|
if (msg.kind === "hello") {
|
||||||
setOccupancy(msg.occupancy);
|
if (msg.occupancy) setOccupancy(msg.occupancy);
|
||||||
// Initial device-status snapshot for the footer.
|
// Initial device-status snapshot for the footer.
|
||||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||||
if (msg.lanes) setLanes(msg.lanes);
|
if (msg.lanes) setLanes(msg.lanes);
|
||||||
@@ -84,7 +85,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
patchPlate(msg.plate.identity, msg.plate.plate);
|
patchPlate(msg.plate.identity, msg.plate.plate);
|
||||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
} else if (msg.kind === "ledger") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
if (msg.occupancy) setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
// Keep Query authoritative: the durable event list, occupancy totals,
|
// Keep Query authoritative: the durable event list, occupancy totals,
|
||||||
// and active-sessions list refetch on the next read instead of trusting
|
// and active-sessions list refetch on the next read instead of trusting
|
||||||
|
|||||||
+19
-18
@@ -45,7 +45,8 @@ import { DrawerManager } from "./DrawerManager.js";
|
|||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
import { BackupSettings } from "./BackupSettings.js";
|
import { BackupSettings } from "./BackupSettings.js";
|
||||||
import { WEB_MODULES } from "./modules/index.js";
|
import { WEB_MODULES } from "./modules/index.js";
|
||||||
import { moduleOn } from "./lib/modules.js";
|
import { canWatchFeed, moduleOn } from "./lib/modules.js";
|
||||||
|
import { TILL_IDS, tillGuards } from "@parking/shared";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
import { Profile } from "./Profile.js";
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
@@ -374,12 +375,13 @@ function RootLayout() {
|
|||||||
// the permission its screen needs (the route guards enforce the same server-side).
|
// the permission its screen needs (the route guards enforce the same server-side).
|
||||||
const show = (perm: Permission) => can(user, perm);
|
const show = (perm: Permission) => can(user, perm);
|
||||||
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
||||||
// for roles the server would accept (routes/ws.ts gates on report:read). A
|
// for roles the server would accept (routes/ws.ts admits any WATCH permission:
|
||||||
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
// event/session/device read, or an effective module's own feed permission — and
|
||||||
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
// then filters what it pushes per role). A merchant validator holds none and must
|
||||||
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
// not even attempt it: the 403'd upgrade would reconnect on backoff forever and
|
||||||
// DeviceFooter → device:read).
|
// spam the server log. Same rule for the widgets that feed off it (StatusDot) or
|
||||||
const canWatch = show("report:read");
|
// make their own gated calls (ShiftButton → shift:read, DeviceFooter → device:read).
|
||||||
|
const canWatch = canWatchFeed(user);
|
||||||
useLiveFeed(canWatch);
|
useLiveFeed(canWatch);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -422,10 +424,10 @@ function RootLayout() {
|
|||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{/* The header button is the BOOTH till's; a role that cannot work the booth
|
{/* The header button is the BOOTH till's, guarded by the booth's own
|
||||||
(no session:read — e.g. the wash operator, who has their own control on
|
shift:read (a wash role holds no shift:* at all and has its own control on
|
||||||
the wash desk) does not get it. The server refuses the same (403). */}
|
the wash desk). The server resolves the same guard from the till. */}
|
||||||
{user && show("shift:read") && show("session:read") && <ShiftButton />}
|
{user && show("shift:read") && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||||
@@ -561,17 +563,16 @@ const drawerRoute = createRoute({
|
|||||||
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
||||||
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
// Anyone who may read a till's drawer, record on one, or review — the component
|
||||||
throw redirect({ to: "/" });
|
// shows the right view per till. (canWatchFeed-style: any of the till guards.)
|
||||||
}
|
const u = context.user;
|
||||||
|
const anyTill = TILL_IDS.some((t) => can(u, tillGuards(t).read) || can(u, tillGuards(t).cash));
|
||||||
|
if (!anyTill && !can(u, "drawer:review")) throw redirect({ to: "/" });
|
||||||
},
|
},
|
||||||
component: function DrawerRoute() {
|
component: function DrawerRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
return (
|
return (
|
||||||
<DrawerManager
|
<DrawerManager user={user} canReview={can(user, "drawer:review")} />
|
||||||
canCreate={can(user, "drawer:create")}
|
|
||||||
canReview={can(user, "drawer:review")}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+102
-20
@@ -87,10 +87,13 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
// action on a fresh appliance, never reachable from the running console. See
|
// action on a fresh appliance, never reachable from the running console. See
|
||||||
// wiki/concepts/backup-recovery.md.
|
// wiki/concepts/backup-recovery.md.
|
||||||
"backup:read", "backup:update", "backup:create",
|
"backup:read", "backup:update", "backup:create",
|
||||||
// Car Wash module (venue-modules.md): read = the wash desk's queue + ticket lookup;
|
// Car Wash module (venue-modules.md): read = the wash desk's queue + ticket lookup
|
||||||
// create = intake an order; update = mark done / take a bay payment / void. Settings
|
// (+ the wash till's shift state and the wash live feed); create = intake an order;
|
||||||
// (categories, services, price matrix, sponsorship program) ride site:update.
|
// update = mark done / take a bay payment / void; cash = WORK the wash till — open and
|
||||||
"carwash:read", "carwash:create", "carwash:update",
|
// close its shift, record its cash in/out (the wash's own `shift:create` +
|
||||||
|
// `drawer:create`; see ModuleManifest.tillGuards). Settings (categories, services,
|
||||||
|
// price matrix, sponsorship program) ride site:update.
|
||||||
|
"carwash:read", "carwash:create", "carwash:update", "carwash:cash",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
||||||
@@ -1805,11 +1808,31 @@ export interface ModuleManifest {
|
|||||||
* operators open shifts on that till and reconcile that drawer. Absent = the
|
* operators open shifts on that till and reconcile that drawer. Absent = the
|
||||||
* module has no money of its own (validation) — or, for parking, the booth. */
|
* module has no money of its own (validation) — or, for parking, the booth. */
|
||||||
readonly till?: TillId;
|
readonly till?: TillId;
|
||||||
/** The permission that lets a role WORK this module's till: open/close its shift and
|
/** Who may SEE and WORK this module's till — each desk's money is guarded by that
|
||||||
* move its cash. The booth's is the booth screen's own (`session:read`); a wash
|
* desk's own permissions (permissions-matrix decision, 2026-09-05): `read` = see the
|
||||||
* operator's role holds `carwash:read` and not that, so they can never open the
|
* shift state / X-report / balance / history; `shift` = open + close the shift;
|
||||||
* booth's shift — and vice versa. Enforced server-side (shift/drawer routes). */
|
* `cash` = record cash in/out. The booth's are parking's `shift:*` / `drawer:*`; the
|
||||||
readonly tillPermission?: Permission;
|
* wash's are `carwash:read` / `carwash:cash`. A wash role holds no `shift:*` at all,
|
||||||
|
* so it cannot touch the booth by construction. Required when `till` is set. */
|
||||||
|
readonly tillGuards?: TillGuards;
|
||||||
|
/** The permission that admits this module's ledger events to a role's live feed
|
||||||
|
* (`ledgerEventTypes` above). Absent = the core `event:read`. */
|
||||||
|
readonly feedPermission?: Permission;
|
||||||
|
/** JOBS — named permission bundles the role composer offers as one click ("Booth
|
||||||
|
* operator", "Wash operator"). The grid stays the enforcement layer; a job is only a
|
||||||
|
* starting point the admin may fine-tune. Names live in the web i18n (`jobs.<id>`). */
|
||||||
|
readonly jobs: readonly JobPreset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TillGuards {
|
||||||
|
readonly read: Permission;
|
||||||
|
readonly shift: Permission;
|
||||||
|
readonly cash: Permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobPreset {
|
||||||
|
readonly id: string;
|
||||||
|
readonly permissions: readonly Permission[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The registry. Adding a module = one entry here + its server/web folders
|
/** The registry. Adding a module = one entry here + its server/web folders
|
||||||
@@ -1822,7 +1845,28 @@ export const MODULES: readonly ModuleManifest[] = [
|
|||||||
resources: ["tariff", "subscription", "payment", "session"],
|
resources: ["tariff", "subscription", "payment", "session"],
|
||||||
ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"],
|
ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"],
|
||||||
till: "booth",
|
till: "booth",
|
||||||
tillPermission: "session:read",
|
tillGuards: { read: "shift:read", shift: "shift:create", cash: "drawer:create" },
|
||||||
|
jobs: [
|
||||||
|
{
|
||||||
|
// Runs the booth: sessions, payments, own shift + drawer, the live feed, devices.
|
||||||
|
id: "booth-operator",
|
||||||
|
permissions: [
|
||||||
|
"session:read", "session:create", "payment:read", "payment:create", "event:read",
|
||||||
|
"shift:read", "shift:create", "drawer:create", "device:read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Everything the operator has, plus what an operator must NOT: voids, every
|
||||||
|
// operator's shifts, drawer review, reports, subscriptions, tariff reading.
|
||||||
|
id: "booth-supervisor",
|
||||||
|
permissions: [
|
||||||
|
"session:read", "session:create", "payment:read", "payment:create", "event:read",
|
||||||
|
"shift:read", "shift:create", "drawer:create", "device:read",
|
||||||
|
"event:void", "shift:cash", "drawer:review", "report:read",
|
||||||
|
"subscription:read", "subscription:create", "subscription:update", "tariff:read", "validation:read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Merchant-scan ticket validation, kept for the Bar until a Bar module absorbs it
|
// Merchant-scan ticket validation, kept for the Bar until a Bar module absorbs it
|
||||||
@@ -1832,6 +1876,9 @@ export const MODULES: readonly ModuleManifest[] = [
|
|||||||
dependsOn: ["parking"],
|
dependsOn: ["parking"],
|
||||||
resources: ["validation"],
|
resources: ["validation"],
|
||||||
ledgerEventTypes: ["validation"],
|
ledgerEventTypes: ["validation"],
|
||||||
|
// A merchant's whole role: scan-and-validate, nothing else. Their validation events
|
||||||
|
// ride the booth log (event:read), so no feed permission of their own.
|
||||||
|
jobs: [{ id: "merchant", permissions: ["validation:create"] }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The pilot module. Depends on parking (the wash sits inside the park; the ticket
|
// The pilot module. Depends on parking (the wash sits inside the park; the ticket
|
||||||
@@ -1844,10 +1891,44 @@ export const MODULES: readonly ModuleManifest[] = [
|
|||||||
ledgerEventTypes: ["carwash_order", "carwash_payment"],
|
ledgerEventTypes: ["carwash_order", "carwash_payment"],
|
||||||
// Money taken AT THE BAY lands on the wash operator's own till, never the booth's.
|
// Money taken AT THE BAY lands on the wash operator's own till, never the booth's.
|
||||||
till: "carwash",
|
till: "carwash",
|
||||||
tillPermission: "carwash:read",
|
tillGuards: { read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" },
|
||||||
|
feedPermission: "carwash:read",
|
||||||
|
jobs: [
|
||||||
|
// Runs the wash desk and its own till; sees nothing of the booth.
|
||||||
|
{ id: "wash-operator", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** The guards of a till (its module's `tillGuards`). */
|
||||||
|
export function tillGuards(till: TillId): TillGuards {
|
||||||
|
const m = MODULES.find((x) => x.till === till);
|
||||||
|
if (!m?.tillGuards) throw new Error(`till without guards: ${till}`);
|
||||||
|
return m.tillGuards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which permission admits a ledger event type to a role's live feed: the owning
|
||||||
|
* module's `feedPermission`, else the core `event:read`. */
|
||||||
|
export function feedPermissionFor(type: LedgerEventType): Permission {
|
||||||
|
const m = MODULES.find((x) => x.ledgerEventTypes.includes(type));
|
||||||
|
return m?.feedPermission ?? "event:read";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every permission that admits a role to the live WebSocket at all (it then receives
|
||||||
|
* only what each permission covers): the core feed/occupancy/device permissions plus
|
||||||
|
* each effective module's own feed permission. `report:read` is NOT among them — the
|
||||||
|
* reports screen and the live feed are different things (user, 2026-09-05). */
|
||||||
|
export function watchPermissions(effective: readonly ModuleId[]): Permission[] {
|
||||||
|
const out = new Set<Permission>(["event:read", "session:read", "device:read"]);
|
||||||
|
for (const m of MODULES) if (m.feedPermission && effective.includes(m.id)) out.add(m.feedPermission);
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which tills a role may work more than one of — the composer's "mixes desks" lint. */
|
||||||
|
export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
|
||||||
|
return tillsFor(effective, has, "shift");
|
||||||
|
}
|
||||||
|
|
||||||
/** The tills available given the EFFECTIVE modules — the booth always (parking is
|
/** The tills available given the EFFECTIVE modules — the booth always (parking is
|
||||||
* required), plus each effective module's own till. Registry order. */
|
* required), plus each effective module's own till. Registry order. */
|
||||||
export function tillsOf(effective: readonly ModuleId[]): TillId[] {
|
export function tillsOf(effective: readonly ModuleId[]): TillId[] {
|
||||||
@@ -1856,15 +1937,16 @@ export function tillsOf(effective: readonly ModuleId[]): TillId[] {
|
|||||||
return TILL_IDS.filter((t) => out.has(t));
|
return TILL_IDS.filter((t) => out.has(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tills a ROLE may work at this site: the effective tills whose module's
|
/** The tills a ROLE may SEE (`kind` = read, default) or WORK (`shift` / `cash`) at
|
||||||
* `tillPermission` the role holds. What the shift/drawer routes enforce and what the
|
* this site: the effective tills whose module guard the role holds. What the
|
||||||
* UI offers (header button, start buttons, drawer switch). */
|
* shift/drawer routes enforce and what the UI offers (header button, start buttons,
|
||||||
export function tillsFor(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
|
* drawer switch). */
|
||||||
const site = tillsOf(effective);
|
export function tillsFor(
|
||||||
return site.filter((t) => {
|
effective: readonly ModuleId[],
|
||||||
const m = MODULES.find((x) => x.till === t);
|
has: (p: Permission) => boolean,
|
||||||
return !!m && (m.tillPermission ? has(m.tillPermission) : true);
|
kind: keyof TillGuards = "read",
|
||||||
});
|
): TillId[] {
|
||||||
|
return tillsOf(effective).filter((t) => has(tillGuards(t)[kind]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Car Wash module ----------------------------------------------------------
|
// --- Car Wash module ----------------------------------------------------------
|
||||||
|
|||||||
+11
-7
@@ -294,13 +294,17 @@ manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
|
|||||||
- "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk
|
- "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk
|
||||||
carries its own shift control. The header button stays the booth's. The shift hub lists
|
carries its own shift control. The header button stays the booth's. The shift hub lists
|
||||||
every open shift with a till badge; the drawer hub switches tills.
|
every open shift with a till badge; the drawer hub switches tills.
|
||||||
- **Working a till needs that till's module permission** (added 2026-09-05 after the user
|
- **Each desk's money is guarded by that desk's own permissions** (2026-09-05, after the
|
||||||
found a wash user could open the *booth's* shift): the manifest names it
|
user found a wash user could open the *booth's* shift; design on [[venue-modules]]
|
||||||
(`tillPermission` — booth: `session:read`, carwash: `carwash:read`), `tillsFor()` in
|
§"Permissions matrix"). The manifest declares `tillGuards { read, shift, cash }`: booth =
|
||||||
`@parking/shared` resolves a role's tills, the shift/drawer routes refuse the rest with
|
`shift:read` / `shift:create` / `drawer:create` (unchanged), carwash = `carwash:read` /
|
||||||
`403 till_forbidden`, and `/api/shift/tills` + `current.tills` return only the role's
|
`carwash:cash` / `carwash:cash`. The shift + drawer routes resolve the guard FROM THE TILL
|
||||||
tills — so the header button, the hub's start buttons and the drawer switch never offer a
|
(`requireTill(kind)`; `403 till_forbidden`), `/api/shift/tills` lists the tills a role may
|
||||||
till the server would refuse. `shift:create` alone opens nothing.
|
read with a `canWork` flag, and history / movements without a till filter return the
|
||||||
|
union of the role's readable tills. So a wash role holds no `shift:*` at all and cannot
|
||||||
|
touch the booth by construction; the header button, the hub's start buttons and the
|
||||||
|
drawer switch never offer a till the server would refuse. (A first cut that borrowed
|
||||||
|
`session:read` as "works the booth till" lived for a few hours and is gone.)
|
||||||
- Not done: the per-shift *activity log* is still a time window over the whole chain (money
|
- Not done: the per-shift *activity log* is still a time window over the whole chain (money
|
||||||
figures are per till, the event list is not); bay slips print on the booth printer.
|
figures are per till, the event list is not); bay slips print on the booth printer.
|
||||||
|
|
||||||
|
|||||||
@@ -138,3 +138,11 @@ procurement. (See [[parking-system-architecture]] §10.)
|
|||||||
payment lands on the wash operator's own till, never the booth's). Open: vision category
|
payment lands on the wash operator's own till, never the booth's). Open: vision category
|
||||||
flag, bay camera, the Bar's scope. Full design and the remaining questions on
|
flag, bay camera, the Bar's scope. Full design and the remaining questions on
|
||||||
[[venue-modules]].
|
[[venue-modules]].
|
||||||
|
|
||||||
|
16. **Permissions matrix after venue modules.** _(Raised by the user, 2026-09-05.)_ The flat
|
||||||
|
`resource:action` grid was composed for one desk; a second desk (Car Wash) exposed borrowed
|
||||||
|
meanings (`session:read` as "works the booth till", `report:read` as "may open the socket")
|
||||||
|
and a composer at the wrong altitude. Decision + three moves (per-desk till guards, jobs on
|
||||||
|
top of the grid, a permission-scoped live feed) on [[venue-modules]] §"Permissions matrix";
|
||||||
|
moves built 2026-09-05. Open: default supervisor bundle, re-applying jobs after a module
|
||||||
|
update, signing role edits.
|
||||||
|
|||||||
@@ -374,6 +374,63 @@ at the two seams the design names, and the registry earned its keep: **one manif
|
|||||||
the next increment, as designed. Receipt label for a booth-paid wash is `Lavazh — <category> ·
|
the next increment, as designed. Receipt label for a booth-paid wash is `Lavazh — <category> ·
|
||||||
<service>` (Albanian, frozen on the payment).
|
<service>` (Albanian, frozen on the payment).
|
||||||
|
|
||||||
|
## Permissions matrix — rethink (OPEN DECISION, raised 2026-09-05; moves 1–2 built same day)
|
||||||
|
|
||||||
|
**Why (user: "I feel we opened Pandora's box with this car wash module. We need to rethink
|
||||||
|
the permissions matrix.").** The flat `resource:action` grid was composed for ONE desk. Three
|
||||||
|
things broke once a second desk existed:
|
||||||
|
|
||||||
|
1. **Permissions named data, not jobs, and their meanings got borrowed.** `session:read` meant
|
||||||
|
"may use the booth screen"; on the first tills cut it also decided who may work the booth
|
||||||
|
till. `report:read` meant "may open the live socket". `shift:create` opened *the* shift. Each
|
||||||
|
was a proxy for a job, and proxies are how the dev `Lavazhier` role ended up with booth
|
||||||
|
rights and without `carwash:create`.
|
||||||
|
2. **Cross-cutting resources have no owner.** Shifts, drawer, events, the feed are core, but
|
||||||
|
every *instance* now belongs to a desk; the grid cannot say "shifts, but only the wash's".
|
||||||
|
3. **The composer is at the wrong altitude.** ~60 checkboxes of nouns and verbs ask the admin to
|
||||||
|
reconstruct a job from parts; at a site where the operator is the adversary a mis-composed
|
||||||
|
role is a security bug.
|
||||||
|
|
||||||
|
**Decision (three moves; the grid stays the enforcement layer — no guard semantics change for
|
||||||
|
the booth).**
|
||||||
|
|
||||||
|
- **Move 1 — each desk's money is guarded by that desk's own permissions.** The manifest
|
||||||
|
declares `tillGuards { read, shift, cash }`: booth = `shift:read` / `shift:create` /
|
||||||
|
`drawer:create` (parking's own, unchanged); carwash = `carwash:read` / **`carwash:cash`** (new)
|
||||||
|
/ `carwash:cash`. Shift + drawer routes resolve the guard FROM THE TILL
|
||||||
|
(`requireTill(kind)`), so a wash role holds no `shift:*` at all and cannot touch the booth by
|
||||||
|
construction; a role that should work both simply holds both. Replaces the one-day-old
|
||||||
|
`session:read` borrowing (`tillPermission`), which is deleted. `/api/shift/tills` lists the
|
||||||
|
tills a role may *read* with a `canWork` flag; history and movements without a till filter
|
||||||
|
return the union of the role's readable tills (admin scopes `shift:cash` / `drawer:review`
|
||||||
|
unchanged).
|
||||||
|
- **Move 2 — jobs on top of the grid.** Manifest `jobs[]` = named permission bundles: parking →
|
||||||
|
*Booth operator*, *Booth supervisor*; validation → *Merchant*; carwash → *Wash operator*. The
|
||||||
|
roles composer offers the jobs of the EFFECTIVE modules as one-click chips (add / remove the
|
||||||
|
bundle), with the grid kept as the fine-tune view, and LINTS the result: **mixes desks** (the
|
||||||
|
role may open more than one till) and **partial job** (holds a module's read permission but
|
||||||
|
not the rest of its job — e.g. a desk that can look but not create). Warnings, not blocks: the
|
||||||
|
admin is not the adversary, but must see what they composed.
|
||||||
|
- **Move 3 — the live feed follows the same rule (user: "The user should have websocket for
|
||||||
|
live events. This does not mean it can read the /reports section.").** The socket is no longer
|
||||||
|
gated on `report:read`. A role may connect if it holds ANY watch permission
|
||||||
|
(`event:read`, `session:read`, `device:read`, or an effective module's `feedPermission` —
|
||||||
|
carwash: `carwash:read`), and each pushed message is FILTERED per role: a ledger event needs
|
||||||
|
`feedPermissionFor(type)` (the owning module's, else `event:read`); occupancy needs
|
||||||
|
`session:read`; device / printer / lane / radar need `device:read`; plate backfill needs
|
||||||
|
`session:read`. So the wash desk gets a live queue without the booth's ledger, and the booth
|
||||||
|
operator keeps a feed without reports. `report:read` now means exactly the reports screen.
|
||||||
|
|
||||||
|
**Rejected.** Scoped permission strings (`shift:create@carwash`) — changes the `Permission`
|
||||||
|
type everywhere for what a manifest lookup expresses; a per-module copy of the shift/drawer
|
||||||
|
resources — the till already IS that copy. Role *templates stored in the DB* — jobs are code
|
||||||
|
(they change with the module), roles are data; keep that line.
|
||||||
|
|
||||||
|
**Status.** Moves 1, 2 and 3 built 2026-09-05 (see the Tills as-built below and [[shift]]
|
||||||
|
§Tills). Open: whether `booth-supervisor` should carry `subscription:*` by default; whether a
|
||||||
|
job should be *re-applicable* after a module update (today a chip only adds/removes the bundle
|
||||||
|
as it is now); an audit `config_change` on role edits.
|
||||||
|
|
||||||
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
|
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
|
||||||
|
|
||||||
**The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide**
|
**The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide**
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ Authentication and authorization, kept **fully local** — a direct consequence
|
|||||||
name. The JWT carries `roleId` (not the permission list); the guard resolves the role's permission
|
name. The JWT carries `roleId` (not the permission list); the guard resolves the role's permission
|
||||||
set per-request from an **in-memory cache** (`bumpPermsCache()` on any role write), so editing a
|
set per-request from an **in-memory cache** (`bumpPermsCache()` on any role write), so editing a
|
||||||
role applies immediately — no re-login, no token bloat. No Casbin/engine needed at this scale.
|
role applies immediately — no re-login, no token bloat. No Casbin/engine needed at this scale.
|
||||||
|
**The token's `roleId` is only a hint (2026-09-05):** after every `jwtVerify` the guard replaces it
|
||||||
|
with the user's CURRENT role from the DB (`refreshRole()`; cached per user, cleared by the same
|
||||||
|
`bumpPermsCache()`, which user update/delete now call), so REASSIGNING a user's role — or deleting
|
||||||
|
the user (→ 401 on their next request) — applies immediately too. Found when a user moved to a new
|
||||||
|
wash role kept the old role's rights until logout.
|
||||||
- **Protected built-in `admin` role** (`id='admin'`, `builtin=1`): non-editable, non-deletable, and
|
- **Protected built-in `admin` role** (`id='admin'`, `builtin=1`): non-editable, non-deletable, and
|
||||||
always resolves to the FULL permission set in code. The app refuses to delete or downgrade the
|
always resolves to the FULL permission set in code. The app refuses to delete or downgrade the
|
||||||
**last user holding admin** — administration can never be locked out of the appliance.
|
**last user holding admin** — administration can never be locked out of the appliance.
|
||||||
|
|||||||
+26
@@ -3003,3 +3003,29 @@ landing, all guards bounce to `/`, `/booth` needs `session:read`. Diagnosed the
|
|||||||
`session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The
|
`session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The
|
||||||
WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills
|
WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills
|
||||||
and [[venue-modules]] §Tills → As-built.
|
and [[venue-modules]] §Tills → As-built.
|
||||||
|
|
||||||
|
## [2026-09-05] ingest | Permissions matrix rethink — three moves built
|
||||||
|
|
||||||
|
User: "we opened Pandora's box with this car wash module … rethink the permissions matrix";
|
||||||
|
and "the user should have websocket for live events — this does not mean it can read
|
||||||
|
/reports". Decision recorded on [[venue-modules]] §"Permissions matrix" (open-questions
|
||||||
|
#16), then built: (1) per-desk till guards — manifest `tillGuards`, new `carwash:cash`,
|
||||||
|
`requireTill(kind)` resolves the guard from the till, `tillPermission`/`session:read`
|
||||||
|
borrowing removed; (2) jobs — manifest `jobs[]` (booth-operator, booth-supervisor,
|
||||||
|
merchant, wash-operator) as one-click chips in Setup → Roles with "mixes desks" / "partial
|
||||||
|
job" lints; (3) the live feed admits any WATCH permission (event/session/device read or a
|
||||||
|
module's `feedPermission`) and filters every push per role — `report:read` is the reports
|
||||||
|
screen only. 352/352 server tests; lavazhier (event:read) now shows LIVE. Their dev role
|
||||||
|
still needs `carwash:cash` (+ create/update) and should drop the booth permissions — the
|
||||||
|
"Wash operator" chip is exactly that.
|
||||||
|
|
||||||
|
## [2026-09-05] ingest | Role reassignment now takes effect without re-login
|
||||||
|
|
||||||
|
User: a user moved to a new "Lavazh NEW" role kept getting `403` on `POST /api/carwash/orders`.
|
||||||
|
Cause: the login token pins the `roleId` current at LOGIN; `/api/auth/me` read the user row (new
|
||||||
|
role) while every guard read the token (old role). Editing a role already took effect per
|
||||||
|
request (the permission cache); reassigning one did not. Fix in `auth.ts`: `refreshRole()` after
|
||||||
|
every `jwtVerify` resolves the user's CURRENT role from the DB (cached per user, cleared by
|
||||||
|
`bumpPermsCache()`, which the user update/delete routes now call); a deleted user's session
|
||||||
|
ends with 401 on its next request; the WS cookie path uses the same. Test: moved user creates
|
||||||
|
an order on the next request with the same cookie. Recorded on [[local-jwt-auth]].
|
||||||
|
|||||||
Reference in New Issue
Block a user