Platform frontend — routing, guards and the nav shell¶
What this covers: the complete route tree of apps/platform-frontend, the three mount groups it is
split into, the two guard components that protect it (RequireAuth for authentication,
RequireRole for authorization), the host-aware /login entry point, the authenticated nav shell
composed in-app per ADR-0057, how navigation entries are permission-filtered, and the
disable-with-reason pattern that replaces hidden controls on gated actions. Application structure,
providers and build/serve are in 01-app-architecture.md.
1. Router shape and the three mount groups¶
main.tsx mounts a component-mode <BrowserRouter> — not a data router. Every route is declared
as JSX inside a single <Routes> element in app/app.tsx. That one choice has consequences that
recur below: no loaders, no actions, no useBlocker, and no route-level error elements.
The tree splits into three groups that differ in what chrome they get and what must be true to reach them.
flowchart TD
routes["Routes — app/app.tsx"]
subgraph grpA["Group A — public, shared AuthShell chrome"]
authlayout["AuthShellLayout — renders AuthShell + Outlet"]
authlayout --> apub["/login · /login/mfa · /login/recovery<br/>/login/platform-admin<br/>/password/forgot · /reset · /reset-expired<br/>/auth/handoff<br/>/workspace/check-inbox · rate-limited<br/>/workspace/not-found · suspended · onboarding"]
end
subgraph grpB["Group B — public, page owns its own AuthShell"]
bpub["/login/mfa-setup · /login/select-tenant<br/>/login/locked · /invite/accept<br/>/sso · /sso/error"]
end
subgraph grpC["Group C — authenticated"]
reqauth["RequireAuth — layout route"]
reqauth --> onboard["/onboarding — standalone chrome,<br/>own AuthShell + NonAdminGuard"]
reqauth --> applayout["AppLayout — nav shell layout route"]
applayout --> pages["Dashboard · Trading · Accounting<br/>Commission · Reference data · Communication<br/>Reports · Admin · Platform"]
end
routes --> authlayout
routes --> bpub
routes --> reqauth
Takeaways
- The public auth screens are siblings of the authenticated group, not children of it. Sign-in is never gated behind the authentication it establishes.
- Group A and Group B exist to avoid a double shell. Pages that already render their own
AuthShell(MFA setup, tenant select, locked, invite, SSO) are mounted standalone; the bareAuthCardpages share oneAuthShellsupplied by theAuthShellLayoutlayout route. /onboardingis authenticated but deliberately outsideAppLayout— it owns its own shell and its own ADMIN gate (NonAdminGuard, keyed off the session identity). Entry is driven by tenant status, never by a nav link.- The workspace edge-state pages (
not-found,suspended,onboarding,check-inbox,rate-limited) are public by necessity: they are the outcomes of hostname resolution and workspace discovery, which happen before any session exists.
Invariant encoded: chrome is owned exactly once per screen. A page either supplies its own
AuthShell or inherits one from a layout route — never both.
2. The route table¶
Every authenticated route below sits under RequireAuth → AppLayout unless noted. All
RequireRole call-sites in app.tsx pass onForbidden="forbidden", which renders the 403 view
in place rather than redirecting.
| Path | Roles required | Notes |
|---|---|---|
/ |
any authenticated | Dashboard |
/notifications/preferences |
any authenticated | ungated |
/reports |
ADMIN · MD · FINANCE | |
/reports/schedules |
ADMIN | |
/audit/:entityType/:entityId |
ADMIN · MD | parameterised only — see §7 |
/admin/users |
ADMIN | |
/admin/config |
ADMIN | |
/platform/tenants/new |
SUPERADMIN | client gate for UX; wizard re-probes platform scope server-side |
/accounting |
ADMIN · FINANCE | invoices list |
/accounting/invoices/:id |
ADMIN · FINANCE | |
/accounting/bank-accounts |
ADMIN · FINANCE | writes further ADMIN-gated in-page |
/commission |
ADMIN · FINANCE · MD · TRADER · VIEWER | feature flag enforced reactively via 403 |
/commission/payouts |
ADMIN · FINANCE | narrowed because the list endpoint is tenant-wide, not trader-scoped |
/commission/:periodId |
ADMIN · FINANCE · MD · TRADER · VIEWER | |
/refdata |
ADMIN · FINANCE | RefDataLayout + <Outlet/>; index redirects to /refdata/exchange-rates |
/refdata/{exchange-rates,customers,customers/:id,products,tables,accounting-months} |
inherited from /refdata |
children are not individually gated |
/communication |
ADMIN · FINANCE | generated documents |
/trading/deals |
TRADING_ROLES | TRADING_ROLES = ADMIN · MD · FINANCE · TRADER · PARTNER_TRADER |
/trading/deals/new |
TRADING_ROLES | a deal is auto-created from its first purchase |
/trading/deals/:id |
TRADING_ROLES | |
/trading/{purchases,sales,haulages,overheads,credit-notes} |
TRADING_ROLES | each with /new and /:id/edit siblings |
/trading/haulage |
none | legacy nav path → <Navigate to="/trading/haulages" replace/> |
Two structural observations that fall straight out of the table:
TRADING_ROLESis declared twice — once inapp/app.tsxfor the route gates and once incomponents/shared/AppLayout.tsxfor the nav visibility gate — with a comment in each asking that they be kept consistent. They are consistent today; nothing enforces it.- Reference-data children inherit their parent's gate. Because the gate is on the
/refdatalayout route, adding a child route under it grants ADMIN·FINANCE access implicitly. That is convenient and also the easiest way to accidentally expose a future sub-page.
3. Guard chain and auth lifecycle¶
stateDiagram-v2
[*] --> Probing
Probing: AuthProvider — GET /users/me in flight
Probing --> Authenticated: 200
Probing --> Unauthenticated: 401 (definitive, never retried)
Probing --> ErrorState: 5xx or network
Authenticated --> RoleCheck: route entered
RoleCheck: RequireRole — hasRole(...roles)
RoleCheck --> Rendered: role held, or SUPERADMIN
RoleCheck --> ForbiddenView: onForbidden = forbidden
RoleCheck --> Redirected: onForbidden = redirect (default, unused in app.tsx)
Unauthenticated --> LoginRedirect: RequireAuth
LoginRedirect: Navigate to /login, replace, state.from = attempted location
Authenticated --> Unauthenticated: refresh failed → onAuthFailure → setQueryData(null)
Authenticated --> Unauthenticated: logout() → revoke → location.replace('/login')
ErrorState: distinct from Unauthenticated — do not show a sign-in screen during an outage
Rendered --> [*]
ForbiddenView --> [*]
Takeaways
AuthProviderrenders aBrandSpinnerwhile probing, soRequireAuthhas no loading branch — by the time it runs, auth is resolved. This is a genuine simplification, and it also means the whole app is blocked on one request at cold start.RequireAuthpreserves the attempted location in router state (state.from), not in the URL.SessionExpiryModaluses the other channel — a?returnTo=query parameter — because it performs a real browser navigation, not a router one.RequireRolealso redirects unauthenticated users (toredirectTo, default/) even though authentication isRequireAuth's job. The source explains this as backward compatibility for older call-sites and tests.- SUPERADMIN passes every
hasRolecheck. The implementation returns true when any role the user holds is in the required set or isSUPERADMIN.getNavItemshas its own matching short-circuit, so the two RBAC surfaces agree. Forbiddenis enumeration-safe by design: it says the user lacks permission without revealing what the resource is or whether it exists. It composes the kitEmptyStatewith arole="region"/aria-labelledbywrapper and a link home.
Invariant encoded: the client gate decides what to show, never what is allowed. The
source header of app/financePermissions.ts states this outright, along with the caveat that
server-side role guards are still missing on accounting-service routes (#1491) — for those routes
the client map is currently the only role check, which is recorded as a known defect.
Spec drift worth naming¶
test/features/route-guards.feature specifies that a TRADER hitting /admin/users is redirected to
/dashboard and shown a toast reading "You do not have permission to access this page". The
as-built behaviour is different and, arguably, better: the route renders the Forbidden view in
place, with no redirect and no toast. The feature file is a specification artifact that has not
been reconciled with the implementation, and it is not executed by the unit-test run (its directory
is outside the Vitest include glob).
4. Deep link → sign-in → return¶
sequenceDiagram
participant U as User
participant R as Router
participant RA as RequireAuth
participant LP as LoginPage
participant SI as SignInPage
participant API as Gateway
U->>R: GET /accounting/invoices/00000000-0000-0000-0000-000000000001
R->>RA: match authenticated group
RA->>R: Navigate('/login', replace, state.from = location)
R->>LP: render /login
LP->>LP: returnTo = safeReturnTo(state.from.pathname ?? ?returnTo=)
LP->>LP: getHostContext() → mode tenant | apex | unknown
LP->>SI: mount SignInPage bound to the resolved workspace
SI->>API: POST /api/v1/auth/login
API-->>SI: 200, Set-Cookie SameSite=Strict
SI->>SI: confirmSession() — removeQueries then invalidate the auth-me key
Note over LP: AuthProvider refetches → isAuthenticated true
LP->>R: Navigate(returnTo, replace)
R->>U: /accounting/invoices/000…001 renders
Takeaways
returnToprecedence is router state first, query second —state.from.pathname ?? ?returnTo=— and the result always passes throughsafeReturnTo.safeReturnTois the single open-redirect guard for every entry point (RequireAuth,SessionExpiryModal,SignInPage, the SSO pages, the SSOHandoffPage). It accepts only a same-origin relative path and collapses everything else to/. It rejects three classes that a naïvestartsWith('/')check misses, because the WHATWG URL parser normalises them before parsing: a protocol-relative//host, a backslash in the second position (/\evil.example, which resolves to//evil.example), and any ASCII control character anywhere in the value (a stripped tab collapses/<TAB>/hostinto//host).confirmSession()(hooks/useSession) is what flips the app to authenticated after an in-SPA sign-in: it removes the cachednulluser and invalidates['auth','me']. It is not needed after the OIDC/SSO callback, which is a full-page 302 back into the SPA and therefore re-runs the probe on a fresh mount.SessionExpiryModaltakes the other path —window.location.assign('/login?returnTo=…')— a real navigation, because the session is dead and the in-memory heap should not survive.
Invariant encoded: a returnTo target is only ever followed if it is a same-origin relative
path, enforced in one function so a fix to the guard covers every caller at once.
5. Host-aware /login¶
/login is one route that mounts three different surfaces depending on the browser's hostname
(ADR-0066 hostname-based tenant resolution, ADR-0069 pre-auth tenant slug resolution).
flowchart TD
login["/login"] --> authed{"already authenticated?"}
authed -- yes --> bounce["Navigate(safeReturnTo(...), replace)"]
authed -- no --> host["getHostContext() — the ONLY window.location.hostname read"]
host --> classify["resolveTenantSlug(hostname, PLATFORM_BASE_DOMAIN) — pure, no network"]
classify -- "hostname === base" --> apex["mode apex → WorkspaceEntryPage"]
classify -- "single valid slug label + base suffix" --> tslug["mode tenant, slug set → SignInPage"]
classify -- "unrelated host" --> tcustom["mode tenant, no slug → SignInPage, resolves by full hostname"]
classify -- "empty or malformed" --> unk["mode unknown → WorkspaceNotFoundPage"]
apex --> paths["remembered workspaces (device-local) · direct slug entry · find-by-email · platform-admin link"]
Takeaways
resolveTenantSlugis a pure mirror of the gateway's resolution precedence — hostname is passed in, never read fromwindow. That purity is what makes it exhaustively unit-testable, and the singlewindow.location.hostnameread is isolated inlib/tenant/host-context.ts.- The gateway remains authoritative. The frontend mirror exists only to decide what to render;
it cannot confirm that a custom domain is registered, so an unrelated host degrades to
mode: 'tenant'with no slug and the page resolves by full hostname against the public resolve endpoint (a 404 becomes the not-found page). - There is deliberately no
'default'fallback anywhere in the contract — the query-parameterdefaultfallback is the defect this design replaced. A legacy?tenant=/?tenantId=URL is handled once at bootstrap bymaybeRedirectLegacyTenantParam(), which fires a deprecation beacon andlocation.replaces to the canonical subdomain; it never establishes tenant context. - Opening a workspace from the apex is a genuine cross-origin navigation to
{slug}.{baseDomain}, never an in-SPA route.host-context.tsbuilds those URLs and preserves the browser's port so local development on:5173does not aim at a dead:80. - The apex discovery surface is enumeration-safe: remembered workspaces come from device-local storage with zero server calls, direct slug entry navigates without confirming existence, and find-by-email always lands on one generic check-inbox page (a 429 diverts to a generic rate-limited page).
/login/platform-adminpinsmode: 'apex'regardless of the resolved host, so operator sign-in is always the platform-admin form and never a tenant credential form.
Invariant encoded: tenant identity is never resolved from a URL path, query parameter or client-supplied header after authentication. Post-auth, tenancy comes from the session.
6. The authenticated nav shell¶
ADR-0057 decided this shell is composed in-app from kit primitives rather than added to
@acme/ui as a generic AppShell. The kit exports AuthShell (a split-panel auth layout) and
primitives, but no top-bar/sidebar application shell, and a single consumer is not enough evidence
to design a reusable one. The trigger to promote it is explicit: a second consumer. Per amendment
A2, the implementer read apps/platform-design-demo/src/app/app.tsx first and documented the
deliberate deviations from it.
┌───────────────┬───────────────────────────────────────────────────────────┐
│ <nav │ <header> │
│ aria-label= │ "Acme Platform" ······· [Commands] [TenantSwitcher] │
│ "Primary │ user name · role chip · tenant name │
│ navigation">│ [SettingsPanel] [NotificationBell] [Sign out] │
│ ├────────────────────────────────────────────┬──────────────┤
│ Dashboard │ <main> │ <aside> │
│ Deals │ │ aria-label= │
│ Purchases │ {children ?? <Outlet />} │ "Contextual" │
│ Sales │ │ 1px rail, │
│ Haulage │ (route element renders here) │ lg+ only │
│ Reports │ │ │
│ Schedules │ │ │
│ Audit Trail │ │ │
│ Users │ │ │
│ Settings │ │ │
│ New tenant │ │ │
│ │ │ │
│ ─ FINANCE ─ │ │ │
│ Accounting │ │ │
│ Commission │ │ │
│ Reference… │ │ │
│ Communicat… │ │ │
└───────────────┴────────────────────────────────────────────┴──────────────┘
SearchOverlay — ⌘K, async, API-backed, entity-grouped (always mounted)
CommandPalette — static nav launcher, opened from the header button
Takeaways
- Four landmarks, deliberately.
<header>,<nav aria-label="Primary navigation">,<main>,<aside aria-label="Contextual">. The demo shell nests its nav inside an<aside>; here the sidebar is the<nav>, and a slim contextual rail supplies the fourth landmark. The rail is currently a 1px border with no content — a documented placeholder for pages to portal into. - Two search surfaces, not one. ADR-0057 amendment A1 found the original "swap
SearchOverlayfor the kitCommandPalette" mapping wrong:CommandPaletteclient-filters a staticcommands[]array, whereasSearchOverlayis an async, debounced, API-backed, entity-grouped search with loading and error states. Both ship: the palette as a nav/action launcher built from the RBAC-filtered finance nav, the overlay as the real entity search. {children ?? <Outlet />}— the layout route feeds pages through<Outlet/>in the real app, while unit tests can pass children directly. Small detail, large effect on shell testability.- Active-state highlighting uses
location.pathname === item.path, an exact match. A nav item is therefore not highlighted while a child route is open (/trading/deals/:iddoes not light up "Deals"). - The tenant name is rendered only when a human-readable
tenantNameis present — the rawtenantId(a GUID) is never shown in the chrome. TenantSwitcherrenders only when the user has more than one membership. ItsonSelectcurrently ignores the chosen id and navigates to/login/select-tenant, because switching re-issues a tenant-scoped session server-side and those endpoints are parked (#1276).
Invariant encoded: the shell is app-local until a second consumer proves the abstraction. ADR-0057 accepts the divergence risk against the demo shell explicitly, on the grounds that a single-consumer "reusable" shell is guesswork.
7. Permission-gated navigation¶
Navigation visibility is computed from two independent sources that are concatenated in the sidebar:
flowchart TD
user["AuthUser.roles"] --> generic["NAV_ITEMS — AppLayout-local array"]
user --> finance["getNavItems(roles) — app/financePermissions.ts"]
generic --> gfilter["filter: item.roles empty OR hasRole(...item.roles)"]
finance --> ffilter["filter: SUPERADMIN sees all, else roles intersect"]
ffilter --> dedupe["exclude ids reporting + platform<br/>— already in NAV_ITEMS"]
gfilter --> render["sidebar: primary list"]
dedupe --> render2["sidebar: FINANCE group, aria-label Finance"]
finance --> palette["CommandPalette commands[] — full finance nav, incl. reporting + platform"]
Takeaways
NAV_ITEMS(dashboard, trading, reports, schedules, audit, users, settings, new tenant) lives inAppLayout;FINANCE_NAV(accounting, commission, reference data, communication, reporting, platform) lives infinancePermissions.tsalongside the role→action map. The shell filters the finance list down to the four module entries to avoid duplicating reporting and platform, which the generic list already provides.- The command palette is fed by the unfiltered
getNavItems(roles)— so reporting and platform appear there as jump targets even though they are suppressed in the sidebar group. canPerform(role, action)is the second half of the same module: a role → finance-action map (invoice:read|approve|void|cancel|generate,erp:manage,commission:view|configure|pay,accounting:manage,report:read|export). ADMIN and SUPERADMIN hold everything; FINANCE deliberately cannoterp:manage,commission:configure,accounting:manage,invoice:generateorinvoice:cancel.- Because there are two nav policies and two role lists, adding a route means touching up to three
places: the route gate in
app.tsx, the nav entry, and — for finance actions — the permission map. Nothing checks that the three agree.
Verified defect. NAV_ITEMS contains { path: '/audit', label: 'Audit Trail' }, but the only
audit route declared is /audit/:entityType/:entityId. There is also no catch-all path="*"
route in the tree. Clicking "Audit Trail" therefore renders the shell with an empty <main> — no
page, no 404, no error. The missing catch-all is a gap in its own right: any mistyped or stale URL
under the authenticated group produces a blank content region rather than a not-found surface.
8. Disable-with-reason instead of hidden controls¶
Trading actions are not hidden when unavailable — they are rendered disabled with the reason
attached. hooks/useGuards.ts computes the reasons; the copy strings are treated as contract and
are not paraphrased.
flowchart TD
start["Action requested on a leg or deal"] --> correction{"post-lock correction?"}
correction -- yes --> creason["creditReason() — allowed on a LOCKED deal"]
correction -- no --> locked{"deal.lockedAt set?"}
locked -- yes --> statusblock["Status-blocked — deal is LOCKED and immutable.<br/>Only the one permitted post-lock correction may be written."]
locked -- no --> rolecheck{"role permits create or edit?"}
rolecheck -- "pure FINANCE" --> financero["Role-restricted — FINANCE is read-only in Trading."]
rolecheck -- no --> rolerestrict["Role-restricted — ROLE cannot create or edit trading records."]
rolecheck -- yes --> partner{"partner tenant?"}
partner -- yes --> partnerblock["Restricted tenant — Partner traders may create and edit drafts only."]
partner -- no --> allow["allowed — reason is null"]
creason --> render
statusblock --> render
financero --> render
rolerestrict --> render
partnerblock --> render
allow --> render["render control: aria-disabled = Boolean(reason), onClick nulled,<br/>Tooltip content = reason"]
Takeaways
- The reason functions are layered in a fixed order: status first (a LOCKED deal freezes everything), then role, then tenant restriction. A LOCKED deal produces a status message even for an ADMIN, because immutability is not a permission question.
- Exactly one narrow category of correction survives the lock, so its controls read
creditReason(), which allows on LOCKED, while the other four leg kinds readtransitionReason/createReason, which do not. The exception is enumerated in one guard rather than decided per call site — the same shape the domain enforces server-side (domain-model.md§2). - Controls use
aria-disabledplus a nulledonClick, never thedisabledattribute. A natively disabled button is not focusable, so its tooltip is unreachable by keyboard and screen reader — the reason would be invisible to exactly the users who most need it. RestrictedBannerrenders the partner-tenant restriction once at the top of trading screens for the tenant-wide case, so the per-control tooltips are not the only signal.useGuardsis consumed by the deal detail header, the leg action bar, the tab strip, the leg list screen and all five leg forms — one policy, many render sites.
Invariant encoded: a disabled control must state why it is disabled, and that statement must be reachable by keyboard. The server remains the authority — a 403 is still possible — so these reasons are affordances, not enforcement.
9. Cross-cutting route UX¶
AppShellwraps the entire<Routes>element withFinanceErrorBoundary(a render crash produces an accessiblerole="alert"fallback with a "Try again" reset, never a white screen) and mountsSessionExpiryModalonce. The boundary is explicitly render-only: auth failures arrive as theonAuthFailuresignal, not as thrown errors, so they are never swallowed by it.SessionExpiryModalsnapshots unsaved form values before the forms unmount, so a mid-session expiry does not silently destroy typed work. The snapshot deliberately skipspassword,email,tel,hidden,autocomplete="off"andaria-hiddencontrols and truncates values at 120 characters to limit PII exposure. Focus trap, Escape handling, backgroundinertand focus restoration come from the kitModal.DirtyFormGuardgives two layers: abeforeunloadlistener while dirty (covers tab close, reload and external navigation) and a kitModalconfirmation — notwindow.confirm— for in-app leaves. Automatic interception of react-router navigations is not implemented, becauseuseBlockerin react-router 6.30 requires a data router and this app mounts a component-mode<BrowserRouter>, where it throws at runtime. Until a data-router migration, the host must drive the prompt viapromptOpen. This is the clearest example of the router choice constraining route UX.
10. Honest assessment¶
Routing here is flat, explicit and static. That is a defensible choice for a moderate route count: everything is visible in one file, the guard chain is two components deep, and there is no loader/action indirection to trace. It is also the reason for most of the friction listed above.
- The route table, the provider composition and the RBAC route policy share a single 537-line file. In-source comments express the intent that verticals fill page bodies without touching it; each new vertical has appended to it instead.
- No catch-all route and one nav entry (
/audit) pointing at no route — together these produce a silent blank screen rather than a 404. - Three places encode the same role facts (route gate, nav entry, permission map) with no
consistency check;
TRADING_ROLESis duplicated verbatim across two files. - No route-level code splitting: every page is statically imported, so there is no lazy boundary at which to add a route-level suspense or error element even if the router supported one.
- Component-mode router rules out
useBlocker, loaders, actions and per-route error elements. The dirty-form guard is the concrete casualty. - The
route-guards.featurespecification and the implementedForbidden-in-place behaviour have diverged, and the feature file is not in the executed test set — so nothing flagged the drift.
Referenced decisions¶
- ADR-0057 — Authenticated nav shell: compose in-app, defer a kit
AppShell(incl. amendment A1 onSearchOverlayvsCommandPalette, and A2 on reading the demo shell first). - ADR-0056 — Frontend MUI →
@acme/uitransition: nested-provider reset/theme ownership. - ADR-0060 — Frontend stack:
@acme/uidesign system + React Query state. - ADR-0058 — Gateway CORS: soft-reject unlisted origins on a same-origin platform.
- ADR-0066 — Hostname-based tenant resolution. ADR-0069 — Pre-auth tenant slug resolution.
- ADR-0052 — Frontend hosting on Azure Static Web Apps. Superseded, never implemented.