bfb6ab0b36
Add a third data stream (app_logs), distinct from the signed ledger and device telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to ship to, so the host is the log store. Backend: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only) with no call-site change; the DB is built before Fastify so the logger has its sink. Frontend (lib/logger.ts): ships failed API requests (minus 401 churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary; console warn/error forwarded only at debug/trace. Batched/throttled POST, sendBeacon on pagehide, loop-safe (never logs the /api/logs call), best-effort everywhere. POST /api/logs (any signed-in user, CSRF, tolerant) + GET /api/logs gated by a new log:read permission (new `log` RBAC resource; admin holds it). Retention: pruned by age + row cap, hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack), sq+en. Migration 0009_app_logs. Verified end-to-end via app.inject: login -> POST 204 -> GET 200 with the record; backend warn/error persisted, info dropped; non-admin GET 403 / POST 204. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
import {
|
|
createRootRouteWithContext,
|
|
createRoute,
|
|
createRouter,
|
|
Link,
|
|
Outlet,
|
|
redirect,
|
|
} from "@tanstack/react-router";
|
|
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
|
import { can, closeShift, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
|
|
import { qk, queryClient } from "./lib/query.js";
|
|
import { setLanguage } from "./lib/i18n/index.js";
|
|
import { applyTheme } 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 { SubscriptionManager } from "./SubscriptionManager.js";
|
|
import { ShiftControl } from "./ShiftControl.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";
|
|
|
|
// 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;
|
|
}
|
|
|
|
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|
component: RootLayout,
|
|
});
|
|
|
|
function NavLink({ to, label }: { to: string; label: string }) {
|
|
return (
|
|
<Link
|
|
to={to}
|
|
className="px-2 py-1 text-[11px] 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-[12px] 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="mx-auto max-w-4xl">
|
|
<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("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
|
|
{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("shift:read") && <SetupTab to="/setup/shifts" label={t("nav.shifts")} />}
|
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
|
</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-[10px] 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-[10px] 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 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);
|
|
|
|
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={() => act(isMine ? "close" : "open")}
|
|
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
|
|
>
|
|
{busy ? t("shift.opening") : label}
|
|
</button>
|
|
{!isOpen && (
|
|
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
|
)}
|
|
{err && <span className="text-[10px] text-term-red">{err}</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="/shift" label={t("nav.shift")} />
|
|
{/* One Setup entry — its tabs hold devices/tariff/subscriptions/site/users/
|
|
roles/shifts. 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("subscription:read") ||
|
|
show("site:read") ||
|
|
show("user:read") ||
|
|
show("role: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} />}
|
|
<StatusDot />
|
|
<span className="text-[11px] text-term-muted">
|
|
{user?.username} · {user?.roleName}
|
|
</span>
|
|
<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: the config screens used to be top-level routes. They now live under
|
|
// /setup as tabs — redirect the old paths so existing bookmarks/links don't 404.
|
|
const legacyRedirects = (
|
|
[
|
|
["/tariff", "/setup/tariff"],
|
|
["/subscriptions", "/setup/subscriptions"],
|
|
["/site", "/setup/site"],
|
|
["/users", "/setup/users"],
|
|
["/roles", "/setup/roles"],
|
|
] as const
|
|
).map(([from, to]) =>
|
|
createRoute({
|
|
getParentRoute: () => rootRoute,
|
|
path: from,
|
|
beforeLoad: () => {
|
|
throw redirect({ to });
|
|
},
|
|
}),
|
|
);
|
|
|
|
const shiftRoute = createRoute({
|
|
getParentRoute: () => rootRoute,
|
|
path: "/shift",
|
|
component: function ShiftRoute() {
|
|
const { user } = rootRoute.useRouteContext();
|
|
// "Admin" actions on the shift screen (drawer cash) need shift:cash.
|
|
return <ShiftControl isAdmin={can(user, "shift:cash")} />;
|
|
},
|
|
});
|
|
|
|
/** 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 /setup/shifts).
|
|
const SETUP_TABS: { to: string; perm: Permission }[] = [
|
|
{ to: "/setup", perm: "site:update" },
|
|
{ to: "/setup/tariff", perm: "tariff:read" },
|
|
{ to: "/setup/subscriptions", perm: "subscription:read" },
|
|
{ to: "/setup/site", perm: "site:read" },
|
|
{ to: "/setup/users", perm: "user:read" },
|
|
{ to: "/setup/roles", perm: "role:read" },
|
|
{ to: "/setup/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 />,
|
|
});
|
|
const subscriptionsRoute = createRoute({
|
|
getParentRoute: () => setupRoute,
|
|
path: "subscriptions",
|
|
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
|
component: () => <SubscriptionManager />,
|
|
});
|
|
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. Gated by shift:read (operators have it) — the SERVER scopes the
|
|
// data: operators see only their own; shift:cash holders see all + can filter.
|
|
const shiftsHistoryRoute = createRoute({
|
|
getParentRoute: () => setupRoute,
|
|
path: "shifts",
|
|
beforeLoad: ({ context }) => requirePerm("shift:read")(context),
|
|
component: function ShiftsHistoryRoute() {
|
|
const { user } = rootRoute.useRouteContext();
|
|
return <ShiftsHistory 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,
|
|
});
|
|
|
|
const routeTree = rootRoute.addChildren([
|
|
indexRoute,
|
|
boothRoute,
|
|
...legacyRedirects,
|
|
shiftRoute,
|
|
setupRoute.addChildren([
|
|
setupDevicesRoute,
|
|
tariffRoute,
|
|
subscriptionsRoute,
|
|
siteRoute,
|
|
usersRoute,
|
|
rolesRoute,
|
|
shiftsHistoryRoute,
|
|
logsRoute,
|
|
]),
|
|
]);
|
|
|
|
export const router = createRouter({
|
|
routeTree,
|
|
context: { user: null, setUser: () => {} },
|
|
defaultPreload: "intent",
|
|
});
|
|
|
|
declare module "@tanstack/react-router" {
|
|
interface Register {
|
|
router: typeof router;
|
|
}
|
|
}
|