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:
@@ -7,18 +7,21 @@ import {
|
||||
fetchEvents,
|
||||
fetchShift,
|
||||
fetchShiftReport,
|
||||
fetchShiftTills,
|
||||
fetchShifts,
|
||||
recordDrawerMovement,
|
||||
reviewDrawerMovement,
|
||||
type DrawerMovement,
|
||||
type MovementStatus,
|
||||
type SessionUser,
|
||||
type ShiftSummary,
|
||||
type TillId,
|
||||
} from "./api.js";
|
||||
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { shiftKey } from "./lib/use-shift.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
|
||||
// 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 qc = useQueryClient();
|
||||
const [till, setTill] = useState<TillId>("booth");
|
||||
// Which tills exist here (the booth + effective money-taking modules') — from the
|
||||
// booth's status read, which every till answer carries.
|
||||
const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") });
|
||||
const tills = status.data?.tills ?? ["booth"];
|
||||
// Which tills this role may READ (each desk's drawer is guarded by that desk's own
|
||||
// permissions) — the first one is the default view.
|
||||
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
|
||||
const tills: TillId[] = status.data?.tills.map((x) => x.till) ?? [];
|
||||
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 = () => {
|
||||
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||
// 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 && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{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`)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -13,12 +13,20 @@ import {
|
||||
type SessionUser,
|
||||
} from "./api.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
|
||||
// matrix of resource × action) and name it; users are then assigned a role. The
|
||||
// built-in `admin` role is shown read-only/locked (it always has every permission
|
||||
// and can't be edited or deleted). The server enforces the same. See
|
||||
// @parking/shared PERMISSIONS.
|
||||
//
|
||||
// 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. */
|
||||
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
||||
@@ -75,6 +83,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
<RoleEditor
|
||||
role={editing === "new" ? null : editing}
|
||||
grouped={grouped}
|
||||
effective={(user?.modules ?? []) as ModuleId[]}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
@@ -124,11 +133,37 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown)
|
||||
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({
|
||||
role, grouped, onCancel, onSubmit,
|
||||
role, grouped, effective, onCancel, onSubmit,
|
||||
}: {
|
||||
role: ManagedRole | null;
|
||||
grouped: Record<string, Permission[]>;
|
||||
effective: readonly ModuleId[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
||||
}) {
|
||||
@@ -141,6 +176,16 @@ function RoleEditor({
|
||||
next.has(p) ? next.delete(p) : next.add(p);
|
||||
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;
|
||||
|
||||
@@ -151,6 +196,34 @@ function RoleEditor({
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</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="mt-1 grid grid-cols-1 gap-1">
|
||||
{Object.entries(grouped).map(([resource, list]) => (
|
||||
|
||||
@@ -22,7 +22,10 @@ import { Spinner } from "./ui/Spinner.js";
|
||||
export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
||||
const { t } = useTranslation();
|
||||
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 [err, setErr] = useState<string | null>(null);
|
||||
// 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 (
|
||||
<div className="flex items-center gap-1">
|
||||
{canWork && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || blockedByOther}
|
||||
@@ -93,6 +97,12 @@ export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
||||
label
|
||||
)}
|
||||
</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 && (
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{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`
|
||||
* 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();
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export interface TillShiftStatus {
|
||||
open: { startedAt: string; operator: string | null } | null;
|
||||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||
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). */
|
||||
drawerMinor: number;
|
||||
currency: string | null;
|
||||
|
||||
@@ -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
|
||||
|
||||
+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