Files
parking_solution/apps/web/src/router.tsx
T
julian 55d6242c7d
CI / check (push) Successful in 46s
Build & push images / images (push) Successful in 2m58s
Build desktop / desktop (push) Successful in 4m53s
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
2026-09-05 14:45:48 +02:00

780 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
createRootRouteWithContext,
createRoute,
createRouter,
Link,
Outlet,
redirect,
} from "@tanstack/react-router";
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
import {
can,
fetchVersion,
logout,
setLanguagePref,
setThemePref,
setFontScalePref,
FONT_SCALE_MIN,
FONT_SCALE_MAX,
FONT_SCALE_STEP,
} from "./api.js";
import { queryClient } from "./lib/query.js";
import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { inTauri } from "./lib/origin.js";
import { DeviceFooter } from "./ui/DeviceFooter.js";
import { ShiftButton } from "./ShiftControl.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
import { TariffLab } from "./TariffLab.js";
import { SubscriptionManager } from "./SubscriptionManager.js";
import { SubscriptionPlansManager } from "./SubscriptionPlansManager.js";
import { SiteSettings } from "./SiteSettings.js";
import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { DrawerManager } from "./DrawerManager.js";
import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js";
import { WEB_MODULES } from "./modules/index.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
// initial bundle and only downloads when an admin opens /setup/reports.
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
// Code-based TanStack Router (no file-based codegen — the app is small enough that
// an explicit tree is clearer). The router context carries the signed-in user and
// a setter so route guards can redirect by role. The root renders the terminal
// chrome (nav + user + live status) and opens the booth WebSocket once, app-wide.
export interface RouterContext {
user: SessionUser | null;
setUser: (u: SessionUser | null) => void;
}
// Exported so a deep component (e.g. the booth pay modal) can read the signed-in user
// from route context without prop-threading through every layer.
export const rootRoute = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
function NavLink({ to, label }: { to: string; label: string }) {
return (
<Link
to={to}
className="px-2 py-1 text-[0.6875rem] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
>
{label}
</Link>
);
}
/** A tab inside the Setup layout. `exact` (activeOptions) so the Devices tab at
* `/setup` doesn't stay highlighted on the child tabs. */
function SetupTab({ to, label, exact = false }: { to: string; label: string; exact?: boolean }) {
return (
<Link
to={to}
activeOptions={{ exact }}
className="border-b-2 border-transparent px-3 py-2 text-[0.75rem] uppercase tracking-wider text-term-muted hover:text-term-text [&.active]:border-term-amber [&.active]:text-term-amber"
>
{label}
</Link>
);
}
/** The running deploy's "<branch>-<short-sha>" (matches the Komodo Stack's TAG in
* komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the
* same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a
* local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */
function VersionBadge() {
const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity });
const version = q.data?.buildVersion;
if (!version) return null;
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
}
/** The installed Tauri app's own "vX.Y.Z" (from tauri.conf.json, synced to the git tag by
* release.yml — see wiki/decisions/desktop-shell-tauri.md) — the client's version, distinct
* from VersionBadge's SERVER build. No-op / renders nothing in a browser (there's no Tauri
* API to call). Was invisible before this: an operator had no way to tell which desktop
* build was actually installed short of reading the update-available prompt. */
function DesktopVersionBadge() {
const [version, setVersion] = useState<string | null>(null);
useEffect(() => {
if (!inTauri()) return;
let cancelled = false;
void import("@tauri-apps/api/app").then(({ getVersion }) =>
getVersion().then((v) => {
if (!cancelled) setVersion(v);
}),
);
return () => {
cancelled = true;
};
}, []);
if (!version) return null;
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
}
/** Desktop-only "change which server this install talks to" control. No-op /
* renders nothing in a browser (the concept doesn't apply — same-origin).
* Simplest correct action: clear the saved backend URL and reload, which
* drops the app back to ConnectScreen (see App.tsx) to re-enter it — this
* mirrors clearing the session (logout → back to Login), not an inline
* editor, since repointing the app is a rare, deliberate admin action. */
function DesktopServerButton() {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
if (!inTauri()) return null;
return (
<>
<button
type="button"
className="btn btn-ghost btn-sm ml-2"
onClick={() => setConfirming(true)}
>
{t("connect.changeServer")}
</button>
{confirming && (
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
<div className="text-[0.8125rem]">
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
{t("subs.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={busy}
onClick={async () => {
setBusy(true);
const { clearBackendUrl } = await import("./lib/backend-config.js");
await clearBackendUrl();
window.location.reload();
}}
>
{busy ? <Spinner /> : t("connect.changeServer")}
</button>
</div>
</div>
</Modal>
)}
</>
);
}
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
* deep links and the back button work and a denied tab redirects to the booth. */
function SetupLayout() {
const { user } = rootRoute.useRouteContext();
const { t } = useTranslation();
const show = (perm: Permission) => can(user, perm);
return (
<div className="">
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
{/* Venue-module setup tabs (e.g. Car wash) — module on AND permission. */}
{WEB_MODULES.flatMap((m) =>
(m.setupNav ?? [])
.filter((n) => moduleOn(user, m.id) && show(n.perm))
.map((n) => <SetupTab key={n.to} to={n.to} label={t(n.labelKey)} />),
)}
{show("site:read") && <VersionBadge />}
<DesktopVersionBadge />
<DesktopServerButton />
</nav>
<Outlet />
</div>
);
}
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
* with tabs for the subscriber catalog and the plan catalog. Each tab is a gated
* child route; an operator with only subscription:read sees just the first tab.
* (The tariff lab moved to /setup/tariff/lab, 2026-07-05 — it tests the tariff, so
* it lives with the tariff.) */
function SubscriptionsLayout() {
const { user } = rootRoute.useRouteContext();
const { t } = useTranslation();
const show = (perm: Permission) => can(user, perm);
return (
<div className="">
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
</nav>
<Outlet />
</div>
);
}
/** Tariff layout — the rate-card hub under Setup: the composer (index) and the
* pricing LAB as sub-tabs. One tariff:read gate on the parent covers both. */
function TariffLayout() {
const { t } = useTranslation();
return (
<div>
<nav className="mb-2 flex flex-wrap items-center gap-1 border-b border-term-border">
<SetupTab to="/setup/tariff" label={t("nav.tariff")} exact />
<SetupTab to="/setup/tariff/lab" label={t("nav.tariffLab")} />
</nav>
<Outlet />
</div>
);
}
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
* and applies it immediately. Updates the router-context user so App re-syncs. */
function LanguageToggle({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
// The ACTIVE language is i18n's own state, not the router-context `user` — the
// latter is captured at route-resolution time and does NOT re-render when we call
// setUser, so reading `user.language` here goes stale after the first switch (the
// highlight froze and the equality guard blocked switching back until a refresh).
// useTranslation() subscribes to i18n's languageChanged, so this stays live.
const { i18n } = useTranslation();
const active = i18n.language as Lang;
async function pick(lang: Lang) {
if (lang === active) return;
setLanguage(lang); // instant UI (fires i18n languageChanged → re-render)
setUser({ ...user, language: lang }); // keep context eventually-consistent + persisted state
try {
await setLanguagePref(lang); // persist
} catch {
/* non-fatal — the choice still applies this session */
}
}
return (
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
{(["sq", "en"] as const).map((l) => (
<button
key={l}
type="button"
onClick={() => pick(l)}
className={`rounded-term px-1.5 py-0.5 ${
active === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`}
>
{l}
</button>
))}
</div>
);
}
/** Dark/light theme toggle. Same shape as the language toggle: applies instantly,
* persists to the user's profile, and updates the router-context user so App
* re-syncs. Restored on the next login from any booth. */
function ThemeToggle({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
const { t } = useTranslation();
// Local state for the ACTIVE theme — same reason as LanguageToggle: the router
// context `user` doesn't re-render on setUser, so reading `user.theme` here froze
// the highlight after one switch and blocked toggling back until a refresh. Seed
// from the prop; update optimistically on pick. App's effect keeps the DOM in sync
// with the persisted user on (re)login.
const [active, setActive] = useState<Theme>(user.theme);
async function pick(theme: Theme) {
if (theme === active) return;
setActive(theme);
applyTheme(theme); // instant UI
setUser({ ...user, theme }); // keep context eventually-consistent + persisted state
try {
await setThemePref(theme); // persist
} catch {
/* non-fatal — the choice still applies this session */
}
}
return (
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
{(["dark", "light"] as const).map((th) => (
<button
key={th}
type="button"
onClick={() => pick(th)}
className={`rounded-term px-1.5 py-0.5 ${
active === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`}
>
{t(th === "dark" ? "common.themeDark" : "common.themeLight")}
</button>
))}
</div>
);
}
/** Header font-size control: A−/value/A+ scaling the whole UI (root font-size). Persisted
* to the user profile like the theme, restored on next login. Local `active` state seeded
* from the prop (the router context doesn't re-render on setUser); App's effect keeps the
* DOM in sync with the persisted user on (re)login. */
function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: SessionUser | null) => void }) {
const { t } = useTranslation();
const [active, setActive] = useState<number>(user.fontScale);
function step(delta: number) {
const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, active + delta));
if (next === active) return;
setActive(next);
applyFontScale(next); // instant UI
setUser({ ...user, fontScale: next });
void setFontScalePref(next).catch(() => {
/* non-fatal — the choice still applies this session */
});
}
const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40";
return (
<div className="flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wider">
<button type="button" className={btn} onClick={() => step(-FONT_SCALE_STEP)} disabled={active <= FONT_SCALE_MIN} title={t("common.fontSmaller")} aria-label={t("common.fontSmaller")}>
A−
</button>
<span className="min-w-[2.5rem] text-center text-term-muted" title={t("common.fontSize")}>{active}%</span>
<button type="button" className={btn} onClick={() => step(FONT_SCALE_STEP)} disabled={active >= FONT_SCALE_MAX} title={t("common.fontLarger")} aria-label={t("common.fontLarger")}>
A+
</button>
</div>
);
}
// Header shift control lives in ShiftControl.tsx (shared with the wash desk, per till).
function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation();
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
// 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 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 (
<div className="flex h-screen flex-col bg-term-bg text-term-text">
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1">
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
{/* Venue-module nav entries (e.g. the Bar merchant's scan-and-validate screen,
often that role's whole nav): shown iff the module is effective at this
site AND the role holds the entry's permission. See lib/modules.ts. */}
{WEB_MODULES.flatMap((m) =>
m.nav
.filter((n) => moduleOn(user, m.id) && show(n.perm))
.map((n) => <NavLink key={n.to} to={n.to} label={t(n.labelKey)} />),
)}
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
user can do either. See wiki/concepts/shift.md. */}
{(show("drawer:create") || show("drawer:review")) && (
<NavLink to="/drawer" label={t("nav.drawer")} />
)}
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
Shown if the user can reach ANY of its tabs. */}
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
<NavLink to="/subscriptions" label={t("nav.subscriptions")} />
)}
{/* Reports — a standalone admin section (own header entry, route /reports). */}
{show("report:read") && <NavLink to="/reports" label={t("nav.reports")} />}
{/* One Setup entry — its tabs hold devices/tariff/site/users/roles/logs.
Shown if the user can reach ANY of those screens (an operator with only
shift:read still gets in, landing on Shifts). */}
{(show("site:update") ||
show("tariff:read") ||
show("site:read") ||
show("user:read") ||
show("role:read") ||
show("recyclebin:read") ||
show("backup:read") ||
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, 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} />}
{canWatch && <StatusDot />}
{user && (
<Link
to="/profile"
title={t("nav.profile")}
className="text-[0.6875rem] text-term-muted hover:text-term-text [&.active]:text-term-amber"
>
{user.username} · {user.roleName}
</Link>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={async () => {
await logout();
setUser(null);
}}
>
{t("common.logout")}
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet />
</main>
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
seed needs device:read (and its live updates ride the report:read WS), so
it's hidden for roles without device visibility (e.g. merchant validators). */}
{user && show("device:read") && <DeviceFooter />}
</div>
);
}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: ({ context }) => {
// Landing = the first screen this role can actually use. The booth for anyone
// with the booth's permission; otherwise the first venue-module landing the role
// holds (wash desk for a wash operator, scan screen for a merchant); otherwise
// the shift hub; otherwise the profile. Every guard that bounces sends people
// HERE (never straight to the booth) so a booth-less role never dead-ends.
throw redirect({ to: landingFor(context.user) });
},
});
function landingFor(user: SessionUser | null): string {
if (can(user, "session:read")) return "/booth";
for (const m of WEB_MODULES) {
if (m.landing && moduleOn(user, m.id) && can(user, m.landing.perm)) return m.landing.to;
}
if (can(user, "shift:read")) return "/shifts";
return "/profile";
}
const boothRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/booth",
// The booth is the parking operator's screen; a role without session:read (a wash
// operator, a merchant) goes to its own landing instead of a screen that 403s.
beforeLoad: ({ context }) => {
if (!can(context.user, "session:read")) throw redirect({ to: "/" });
},
component: BoothScreen,
});
// Back-compat redirects for paths that moved. Most config screens live under /setup;
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
// bookmarks/links don't 404. (No "/subscriptions" entry: that's now a REAL route.)
const legacyRedirects = (
[
["/tariff", "/setup/tariff"],
["/site", "/setup/site"],
["/users", "/setup/users"],
["/roles", "/setup/roles"],
["/shift", "/shifts"],
["/setup/subscriptions", "/subscriptions"],
["/setup/plans", "/subscriptions/plans"],
// The tariff lab bounced twice: /setup/tariff-lab → /subscriptions/tariff-lab
// (2026-06-21) → /setup/tariff/lab (2026-07-05, back with the tariff it tests).
["/setup/tariff-lab", "/setup/tariff/lab"],
["/subscriptions/tariff-lab", "/setup/tariff/lab"],
["/setup/shifts", "/shifts"],
["/setup/reports", "/reports"],
] as const
).map(([from, to]) =>
createRoute({
getParentRoute: () => rootRoute,
path: from,
beforeLoad: () => {
throw redirect({ to });
},
}),
);
// Admin reports/charts — a top-level section (own header nav entry), NOT a Setup tab.
// Gated by report:read. Lazy component (Recharts) in a Suspense so it stays out of the
// booth's initial bundle.
const reportsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/reports",
beforeLoad: ({ context }) => requirePerm("report:read")(context),
component: function ReportsRoute() {
return (
<Suspense fallback={<div className="p-3 text-term-muted">…</div>}>
<Reports />
</Suspense>
);
},
});
const shiftRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/shifts",
component: function ShiftRoute() {
const { user } = rootRoute.useRouteContext();
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
// The CURRENT shift's pane carries the actions (open/close, takings), each opening a
// modal. `canManage` = shift:create (start/end). Drawer cash movements moved to /drawer
// (2026-07-01).
return <ShiftsHistory user={user} canManage={can(user, "shift:create")} />;
},
});
const drawerRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/drawer",
// Reachable by anyone who can record OR review; the component shows the right view per
// 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 }) => {
// 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 user={user} canReview={can(user, "drawer:review")} />
);
},
});
/** Guard factory: a route requiring `perm` redirects a user who lacks it back to
* the booth. Same permission the server enforces — defence in depth, not the only
* gate. */
function requirePerm(perm: Permission) {
return (ctx: RouterContext) => {
if (!can(ctx.user, perm)) throw redirect({ to: "/booth" });
};
}
// The Setup tabs in display order, each with the permission its screen needs. Used
// to land a user on the FIRST tab they may see when they open /setup without
// `site:update` (e.g. an operator who only has shift:read → goes to the standalone
// /shifts hub, which is no longer a Setup tab).
const SETUP_TABS: { to: string; perm: Permission }[] = [
{ to: "/setup", perm: "site:update" },
{ to: "/setup/tariff", perm: "tariff:read" },
{ to: "/setup/site", perm: "site:read" },
{ to: "/setup/users", perm: "user:read" },
{ to: "/setup/roles", perm: "role:read" },
{ to: "/setup/recycle-bin", perm: "recyclebin:read" },
{ to: "/shifts", perm: "shift:read" },
{ to: "/setup/logs", perm: "log:read" },
];
// /setup is a LAYOUT route (tab bar + <Outlet>); the config screens are its
// children. The layout itself has no permission gate — each child enforces its own
// (so a user who can reach ANY tab gets the hub, but only the tabs they're allowed).
const setupRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/setup",
component: SetupLayout,
});
// Index tab = Devices (the former SetupWizard). Lives at /setup exactly. A user who
// lacks site:update (e.g. an operator) is redirected to the FIRST tab they CAN see
// rather than bounced to the booth — so "Setup" always lands somewhere useful.
const setupDevicesRoute = createRoute({
getParentRoute: () => setupRoute,
path: "/",
beforeLoad: ({ context }) => {
if (can(context.user, "site:update")) return;
const firstOther = SETUP_TABS.find((tab) => tab.to !== "/setup" && can(context.user, tab.perm));
throw redirect({ to: firstOther?.to ?? "/booth" });
},
component: () => <SetupWizard />,
});
const tariffRoute = createRoute({
getParentRoute: () => setupRoute,
path: "tariff",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: TariffLayout,
});
const tariffComposerRoute = createRoute({
getParentRoute: () => tariffRoute,
path: "/",
component: () => <TariffComposer />,
});
// The tariff LAB — lives with the tariff it tests (moved from /subscriptions,
// 2026-07-05). The parent's tariff:read gate covers it.
const tariffLabRoute = createRoute({
getParentRoute: () => tariffRoute,
path: "lab",
component: () => <TariffLab />,
});
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
const subscriptionsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/subscriptions",
component: SubscriptionsLayout,
});
// Index tab = the subscriber catalog at /subscriptions exactly. A user lacking
// subscription:read is redirected to the first sub-tab they CAN see (or the booth).
const subscriptionsIndexRoute = createRoute({
getParentRoute: () => subscriptionsRoute,
path: "/",
beforeLoad: ({ context }) => {
if (can(context.user, "subscription:read")) return;
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
throw redirect({ to: "/booth" });
},
component: function SubscriptionsRoute() {
const { user } = rootRoute.useRouteContext();
return <SubscriptionManager user={user} />;
},
});
const subscriptionPlansRoute = createRoute({
getParentRoute: () => subscriptionsRoute,
path: "plans",
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
component: () => <SubscriptionPlansManager />,
});
const siteRoute = createRoute({
getParentRoute: () => setupRoute,
path: "site",
beforeLoad: ({ context }) => requirePerm("site:read")(context),
component: function SiteRoute() {
const { user } = rootRoute.useRouteContext();
return <SiteSettings canEdit={can(user, "site:update")} />;
},
});
const usersRoute = createRoute({
getParentRoute: () => setupRoute,
path: "users",
beforeLoad: ({ context }) => requirePerm("user:read")(context),
component: function UsersRoute() {
const { user } = rootRoute.useRouteContext();
return <UsersManager user={user} />;
},
});
const rolesRoute = createRoute({
getParentRoute: () => setupRoute,
path: "roles",
beforeLoad: ({ context }) => requirePerm("role:read")(context),
component: function RolesRoute() {
const { user } = rootRoute.useRouteContext();
return <RolesManager user={user} />;
},
});
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
// Recycle bin — restore/purge soft-deleted master data. Gated by recyclebin:read.
const recycleBinRoute = createRoute({
getParentRoute: () => setupRoute,
path: "recycle-bin",
beforeLoad: ({ context }) => requirePerm("recyclebin:read")(context),
component: function RecycleBinRoute() {
const { user } = rootRoute.useRouteContext();
return <RecycleBin user={user} />;
},
});
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
const logsRoute = createRoute({
getParentRoute: () => setupRoute,
path: "logs",
beforeLoad: ({ context }) => requirePerm("log:read")(context),
component: LogsViewer,
});
// Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create).
const backupRoute = createRoute({
getParentRoute: () => setupRoute,
path: "backup",
beforeLoad: ({ context }) => requirePerm("backup:read")(context),
component: BackupSettings,
});
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
const profileRoute = createRoute({
getParentRoute: () => rootRoute,
path: "profile",
component: function ProfileRoute() {
const { user, setUser } = rootRoute.useRouteContext();
if (!user) return null;
return <Profile user={user} setUser={setUser} />;
},
});
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
// Venue-module routes (e.g. /validate) — each module gates its own routes on
// moduleOn + permission. See modules/index.ts.
...WEB_MODULES.flatMap((m) => m.routes(rootRoute)),
...legacyRedirects,
profileRoute,
shiftRoute,
drawerRoute,
reportsRoute,
subscriptionsRoute.addChildren([
subscriptionsIndexRoute,
subscriptionPlansRoute,
]),
setupRoute.addChildren([
setupDevicesRoute,
tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]),
siteRoute,
usersRoute,
rolesRoute,
recycleBinRoute,
logsRoute,
backupRoute,
...WEB_MODULES.flatMap((m) => m.setupRoutes?.(setupRoute) ?? []),
]),
]);
export const router = createRouter({
routeTree,
context: { user: null, setUser: () => {} },
defaultPreload: "intent",
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}