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:
@@ -911,6 +911,17 @@ export const en: Catalog = {
|
||||
permCount_other: "{{count}} permissions",
|
||||
userCount_one: "{{count}} user",
|
||||
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: {
|
||||
label: "Shift:",
|
||||
|
||||
@@ -925,6 +925,17 @@ export const sq = {
|
||||
permCount_other: "{{count}} leje",
|
||||
userCount_one: "{{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: {
|
||||
label: "Turni:",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AnyRoute } from "@tanstack/react-router";
|
||||
import type { ModuleId } from "@parking/shared";
|
||||
import type { Permission, SessionUser } from "../api.js";
|
||||
import { watchPermissions, type ModuleId } from "@parking/shared";
|
||||
import { can, type Permission, type SessionUser } from "../api.js";
|
||||
import type { rootRoute } from "../router.js";
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
to: string;
|
||||
/** 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). */
|
||||
type WsMessage =
|
||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||
// Parts a role may not see arrive as null (the server filters per role — ws.ts).
|
||||
| { 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: "device-status"; event: DeviceStatus }
|
||||
| { kind: "lane-status"; lanes: LaneStatus }
|
||||
@@ -67,7 +68,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
||||
return; // ignore malformed frames
|
||||
}
|
||||
if (msg.kind === "hello") {
|
||||
setOccupancy(msg.occupancy);
|
||||
if (msg.occupancy) setOccupancy(msg.occupancy);
|
||||
// Initial device-status snapshot for the footer.
|
||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||
if (msg.lanes) setLanes(msg.lanes);
|
||||
@@ -84,7 +85,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
||||
patchPlate(msg.plate.identity, msg.plate.plate);
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
} else if (msg.kind === "ledger") {
|
||||
setOccupancy(msg.occupancy);
|
||||
if (msg.occupancy) setOccupancy(msg.occupancy);
|
||||
pushEvent(msg.event);
|
||||
// Keep Query authoritative: the durable event list, occupancy totals,
|
||||
// and active-sessions list refetch on the next read instead of trusting
|
||||
|
||||
Reference in New Issue
Block a user