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:
+19
-18
@@ -45,7 +45,8 @@ import { DrawerManager } from "./DrawerManager.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
import { BackupSettings } from "./BackupSettings.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 { Profile } from "./Profile.js";
|
||||
// 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).
|
||||
const show = (perm: Permission) => can(user, perm);
|
||||
// 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
|
||||
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
||||
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
||||
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
||||
// DeviceFooter → device:read).
|
||||
const canWatch = show("report:read");
|
||||
// for roles the server would accept (routes/ws.ts admits any WATCH permission:
|
||||
// event/session/device read, or an effective module's own feed permission — and
|
||||
// then filters what it pushes per role). A merchant validator holds none and must
|
||||
// not even attempt it: the 403'd upgrade would reconnect on backoff forever and
|
||||
// spam the server log. Same rule for the widgets that feed off it (StatusDot) or
|
||||
// make their own gated calls (ShiftButton → shift:read, DeviceFooter → device:read).
|
||||
const canWatch = canWatchFeed(user);
|
||||
useLiveFeed(canWatch);
|
||||
|
||||
return (
|
||||
@@ -422,10 +424,10 @@ function RootLayout() {
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{/* The header button is the BOOTH till's; a role that cannot work the booth
|
||||
(no session:read — e.g. the wash operator, who has their own control on
|
||||
the wash desk) does not get it. The server refuses the same (403). */}
|
||||
{user && show("shift:read") && show("session:read") && <ShiftButton />}
|
||||
{/* The header button is the BOOTH till's, guarded by the booth's own
|
||||
shift:read (a wash role holds no shift:* at all and has its own control on
|
||||
the wash desk). The server resolves the same guard from the till. */}
|
||||
{user && show("shift:read") && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
{user && <ThemeToggle 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
|
||||
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
// Anyone who may read a till's drawer, record on one, or review — the component
|
||||
// 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() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return (
|
||||
<DrawerManager
|
||||
canCreate={can(user, "drawer:create")}
|
||||
canReview={can(user, "drawer:review")}
|
||||
/>
|
||||
<DrawerManager user={user} canReview={can(user, "drawer:review")} />
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user