fix(web): permission-degrade the app shell for merchant-only users
Build desktop / desktop (push) Successful in 4m51s
Build & push images / images (push) Successful in 3m8s
CI / check (push) Successful in 52s

A user whose role has only validation:create (the bar/lavazh validator) made
the shell misbehave: useLiveFeed() connected /api/ws unconditionally, the
server's report:read guard 403'd the upgrade, and the capped-backoff
reconnect hammered it forever — a 403 in the server log every few seconds.
Gate the socket on report:read (mirrors routes/ws.ts WATCH_PERMISSION) and
render StatusDot / ShiftButton / DeviceFooter only with their backing
permissions (report:read / shift:read / device:read), so a merchant's shell
is just the nav + their /validate screen, with zero doomed requests.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
This commit is contained in:
2026-07-13 20:12:43 +02:00
parent 0ed43239c3
commit 19dff97c74
3 changed files with 34 additions and 10 deletions
+15 -3
View File
@@ -23,7 +23,14 @@ type WsMessage =
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
export function useLiveFeed(): void {
/**
* @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's
* WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator
* with only `validation:create`) must not attempt the socket at all: the server
* 403s the upgrade and the capped-backoff reconnect would otherwise hammer it
* forever, filling the server log with a 403 every few seconds.
*/
export function useLiveFeed(enabled: boolean = true): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
useLiveStore();
@@ -34,6 +41,10 @@ export function useLiveFeed(): void {
const closedRef = useRef(false);
useEffect(() => {
if (!enabled) {
setStatus("closed");
return;
}
closedRef.current = false;
const connect = () => {
@@ -117,7 +128,8 @@ export function useLiveFeed(): void {
sockRef.current?.close();
sockRef.current = null;
};
// qc / store setters are stable; run once on mount.
// qc / store setters are stable; re-run only if the permission gate flips
// (login as a different role without a full reload).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [enabled]);
}