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:
@@ -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 })}
|
||||
|
||||
Reference in New Issue
Block a user