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:
@@ -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`
|
||||
* 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. */
|
||||
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 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
|
||||
@@ -70,7 +70,8 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch
|
||||
void status.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[] = [];
|
||||
openTills.forEach((t, i) => {
|
||||
const x = reports.data?.[i];
|
||||
@@ -98,7 +99,7 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch
|
||||
isMine: t.isMine,
|
||||
});
|
||||
});
|
||||
return { current, tills, refetch };
|
||||
return { current, tills, workable, refetch };
|
||||
}
|
||||
|
||||
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 [tillFilter, setTillFilter] = useState<TillId | "">("");
|
||||
|
||||
const { current, tills, refetch: refetchCurrent } = useCurrentShifts();
|
||||
const { current, tills, workable, refetch: refetchCurrent } = useCurrentShifts();
|
||||
const multiTill = tills.length > 1;
|
||||
|
||||
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
|
||||
}, [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 startable = tills.filter((x) => !openOn.has(x));
|
||||
const startable = workable.filter((x) => !openOn.has(x));
|
||||
|
||||
function refreshAll() {
|
||||
void q.refetch();
|
||||
|
||||
Reference in New Issue
Block a user