Files
parking_solution/apps/web/src/router.tsx
T
julian d5e41500a8
Build desktop / desktop (push) Successful in 4m42s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 39s
feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup ->
Backup), not a server env var. An env-pinned target defeats the purpose: the admin
can't point backups at a freshly-plugged USB or a NAS mount without editing .env
and restarting. The encryption key stays a server secret.

Target storage:
- New site_config.backup_target_dir (migration 0016, nullable; null = not
  configured). BackupService reads it fresh each run, so a UI change takes effect
  with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it
  backs up.

Routes:
- PUT /api/backup/config  -- set/clear the target (backup:update; upserts id=1).
- POST /api/backup/test   -- probe a candidate path server-side (exists / is a
  directory / writable) so the admin gets feedback before relying on it.
- status() now exposes targetDir + keyPresent, so the UI distinguishes
  'no target set' from 'BACKUP_KEY missing'.

UI (apps/web/src/BackupSettings.tsx):
- A Setup -> Backup tab (gated backup:read): an editable target-path field with a
  Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status
  panel (config state, last-run size/pruned/error, a distinct amber missing-key
  warning), a Back up now button, and the restore-is-out-of-band note. Full i18n
  (sq + en); nav.backup.
- API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup.

Also includes a small in-progress copy trim to the setup-intro i18n strings.

Verified live with Playwright: typed a path -> Test reported writable -> Save
persisted it -> status reflected it and showed the key-missing warning. Whole
monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 12:21:26 +02:00

755 lines
29 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, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
import {
can,
closeShift,
fetchShiftReport,
logout,
openShift,
setLanguagePref,
setThemePref,
setFontScalePref,
FONT_SCALE_MIN,
FONT_SCALE_MAX,
FONT_SCALE_STEP,
} from "./api.js";
import { qk, queryClient } from "./lib/query.js";
import { Modal } from "./ui/Modal.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.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 { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js";
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>
);
}
/** 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")} />}
</nav>
<Outlet />
</div>
);
}
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
* with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each
* tab is a gated child route; an operator with only subscription:read sees just the
* first tab. */
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")} />}
{show("tariff:read") && <SetupTab to="/subscriptions/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 — the site-wide single-open shift expressed as one button:
* - no shift open → "Open shift" (enabled; opens this operator's shift)
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
* - another's shift open → disabled, labelled with who holds it (you can neither
* open yours nor close theirs until they hand over).
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
*/
function ShiftButton() {
const { t } = useTranslation();
const qc = useQueryClient();
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Closing a shift signs the Z-report and is irreversible, so the header button never
// closes directly (a stray click would end the shift) — it opens a confirm modal that
// shows the live X-report first. Opening a shift has no such risk → immediate.
const [confirmingClose, setConfirmingClose] = useState(false);
function onClick() {
if (isMine) {
setConfirmingClose(true);
} else {
void act("open");
}
}
async function act(kind: "open" | "close") {
setBusy(true);
setErr(null);
try {
if (kind === "open") await openShift();
else await closeShift();
// The shift boundary moves: refresh status, the per-shift log window, drawer.
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
// Disabled when another operator holds the shift (can't open or close).
const label = blockedByOther
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
: isMine
? t("shift.headerClose")
: t("shift.headerOpen");
const tone = blockedByOther
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
: isMine
? "border-term-red text-term-red hover:bg-term-red/10"
: "border-term-green text-term-green hover:bg-term-green/10";
return (
<div className="flex items-center gap-1">
<button
type="button"
disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={onClick}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`}
>
{busy ? t("shift.opening") : label}
</button>
{!isOpen && (
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)}
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
{confirmingClose && (
<CloseShiftConfirm
busy={busy}
onCancel={() => setConfirmingClose(false)}
onConfirm={async () => {
await act("close");
setConfirmingClose(false);
}}
/>
)}
</div>
);
}
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
* expected drawer before committing the irreversible Z-report. */
function CloseShiftConfirm({
busy,
onCancel,
onConfirm,
}: {
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
const x = q.data;
const cur = x?.currency ?? null;
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
return (
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
<div className="text-[0.8125rem] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
) : (
<>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
part is broken out below it; subscription SALES is not (it's the remainder). */}
<span />
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
<span />
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div>
</>
)}
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
{t("subs.cancel")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
{busy ? t("shift.ending") : t("shift.endShift")}
</button>
</div>
</div>
</Modal>
);
}
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
>
{label}
</span>
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
</div>
);
}
function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation();
// One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed();
// 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);
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">
<NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shifts" label={t("nav.shifts")} />
{/* 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">
{user && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />}
{user && <FontScaleToggle user={user} setUser={setUser} />}
<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. */}
{user && <DeviceFooter />}
</div>
);
}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
throw redirect({ to: "/booth" });
},
});
const boothRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/booth",
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"],
["/setup/tariff-lab", "/subscriptions/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, drawer voucher, takings),
// each opening a modal. `canManage` = shift:create (start/end + raise vouchers); a
// voucher additionally needs an admin's password sign-off server-side.
return (
<ShiftsHistory
user={user}
canManage={can(user, "shift:create")}
canVoucher={can(user, "shift:create")}
/>
);
},
});
/** 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: () => <TariffComposer />,
});
// --- /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" });
if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" });
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 tariffLabRoute = createRoute({
getParentRoute: () => subscriptionsRoute,
path: "tariff-lab",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffLab />,
});
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,
...legacyRedirects,
profileRoute,
shiftRoute,
reportsRoute,
subscriptionsRoute.addChildren([
subscriptionsIndexRoute,
subscriptionPlansRoute,
tariffLabRoute,
]),
setupRoute.addChildren([
setupDevicesRoute,
tariffRoute,
siteRoute,
usersRoute,
rolesRoute,
recycleBinRoute,
logsRoute,
backupRoute,
]),
]);
export const router = createRouter({
routeTree,
context: { user: null, setUser: () => {} },
defaultPreload: "intent",
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}