Skip to content

RouteGuards

Paulo Gomes da Cruz Junior edited this page Oct 23, 2026 · 3 revisions

Route Guards

Access control in the frontend is enforced by pathless layout routes (React components that wrap a subtree and redirect before rendering) and beforeLoad hooks (router-level checks that run before any component renders). The previous guard system — higher-order components applied by applyGuards() to a hand-built route registry — was removed with the registry itself in the file-based router migration.

There is no applyGuards, no RouteDescription.guards array, and no src/routes/guards/ directory. A route's access rules are now decided by where its file sits in src/routes/ and what that route declares in beforeLoad.

Old guard → new mechanism

Old HOC guard New mechanism Where
withProtected _authenticated pathless layout: auth + status gate src/routes/_authenticated.tsx
withPublicOnly Public-only landing behavior in the / route component src/routes/index.tsx
Route-level roles on RouteDescription Role-tier layouts: _admin (ADMIN), _client-access (any-of VIEWER/SUBMITTER) src/routes/_authenticated/_admin.tsx, src/routes/_authenticated/_client-access.tsx
withFeatureFlag beforeLoad that throws notFound() when the flag is off each gated route file (see table below)
withPersistentRedirect DashboardRedirect component on /dashboard src/routes/dashboard.tsx
withOfflineSupport Removed as a route gate. offlineOnly / offlineReady survive as staticData consumed by the nav projection only (no route is blocked by connectivity anymore) src/routing/nav/useNavEntries.ts
navigateInTree / InTreePath Removed. Navigation is typed directly through the single Register on the router — navigate({ to, params }) and Link to accept only real route paths src/router.tsx

Auth gate: _authenticated

File: frontend/src/routes/_authenticated.tsx

A pathless layout (no URL segment) wrapping every protected URL. It is a React layout rather than a beforeLoad hook because authentication state lives in React context (useAuth) — a hook outside React would need a duplicated, non-React snapshot of the same state, and the two could disagree.

Behavior, in decision order (pure logic in computeAuthRedirect, src/routing/auth.ts):

  1. Auth resolving → Carbon Loading overlay; no decisions while isLoading
  2. No user → persist pathname + searchStr to sessionStorage (redirectAfterLogin), navigate to /login (external Cognito URL) — except when the match is the unknown-URL catch-all, or the pathname is /login itself
  3. No user on the catch-all (routeId === '/_authenticated/$') → navigate to / without persisting (no real destination to restore)
  4. Status no-role → /no-role
  5. Status role-error → /unauthorized?reason=<CODE>, except when already on /unauthorized (loop guard)
  6. All checks pass → one <Layout><Outlet /></Layout> — page components never wrap themselves in Layout

The redirect fires from useLayoutEffect so it commits before the browser paints (no flash of gated content).

Effect-dependency rule: the redirect effect depends on primitives only. Depending on a fresh per-render object (like the whole { href } result) re-fires the effect each transition and each new navigate() cancels the pending one — an infinite cancel/restart loop that never commits the hop. The routing access-matrix tests caught exactly this before release.

Role tiers

Files: frontend/src/routes/_authenticated/_admin.tsx, frontend/src/routes/_authenticated/_client-access.tsx

Tier REQUIRED_ROLES Covers
_admin ADMIN /configuration subtree
_client-access any-of VIEWER, SUBMITTER /clients

Shared pattern (both files are structurally identical apart from the roles const):

  • one REQUIRED_ROLES const — the single declaration point for the tier;
  • hasAnyRole(user.roles, REQUIRED_ROLES) — any-of comparison on FamRole.role (assignments are FamRole objects, not strings);
  • useLayoutEffect navigates denied users to /unauthorized before first paint (no-flash);
  • Loading overlay while pending or denied-but-not-yet-redirected;
  • staticData.roles mirrors the list as nav metadata only — the layout component is the gate. The nav projection reads staticData so it can filter menu entries without importing route components;
  • routes inherit the tier by living in the tier's folder: _admin/configuration.tsx and its children are ADMIN-gated by placement, with no per-route role declaration.

Feature-flag gates: beforeLoad

Feature flags are checked in the route's beforeLoad by throwing a router-level 404:

beforeLoad: () => {
  if (!featureFlags['reporting-unit-details-enabled']) throw notFound();
},
Flag Gate location Covers
reporting-unit-create-enabled src/routes/_authenticated/reporting-units/create.tsx /reporting-units/create
reporting-unit-details-enabled src/routes/_authenticated/reporting-units/$ruId/index.tsx /reporting-units/$ruId
reporting-unit-block-details-enabled src/routes/_authenticated/reporting-units/$ruId/$blockId.tsx /reporting-units/$ruId/$blockId
configuration-enabled src/routes/_authenticated/_admin/configuration.tsx entire /configuration subtree — one check; children never repeat it

Why beforeLoad for flags but a layout for auth: flags are synchronous environment values readable anywhere; auth is React context, invisible to beforeLoad. Each check lives where its data lives.

Consequence — flags run first: beforeLoad executes before the React auth gate renders. An unauthenticated user on a disabled route gets the auth-aware 404 (redirect to / to sign in), not a login prompt for a nonexistent page: disabled means absent, for everyone. And an authenticated non-admin on a disabled /configuration sees a 404 rather than /unauthorized — the flag check precedes the role tier. Both outcomes are the accepted fail-closed trade-off of putting flags in beforeLoad.

Each gated route also declares staticData.featureFlag so useNavEntries hides the side-nav entry while the flag is off.

Public-only pages

File: frontend/src/routes/index.tsx

The / landing route keeps public-only semantics in its component (PublicOnlyLanding): while auth resolves it renders a labelled Loading overlay, authenticated users are redirected to /dashboard in a useLayoutEffect (no landing flash), unauthenticated users see LandingPage. No wrapper HOC — the behavior is the route component itself.

Post-login redirect

File: frontend/src/routes/dashboard.tsx

DashboardRedirect on /dashboard (top-level, outside _authenticated) resolves where a freshly authenticated user lands, once per mount via a useRef:

  1. Wait for auth to resolve.
  2. Stored safe target in sessionStorage → clear it, navigate there.
  3. OAuth callback (code + state query params) → /search.
  4. Otherwise → /search, passing non-OAuth query params through.

getSafeRedirectTarget (src/routing/safeRedirect.ts) rejects empty values, non-path URLs (no leading /, or //), cross-origin absolute URLs (open-redirect protection), and /dashboard itself (self-target loop). See Routing for the sequence diagram.

Design rules (current system)

Rule Rationale
Auth/status/role decisions in layout components, flag decisions in beforeLoad Each check runs where its data lives: React context vs synchronous env values
Redirect from useLayoutEffect, never useEffect The hop commits before paint — no flash of gated content
Effect deps must be primitives A fresh object dep re-fires the effect every render; each new navigate() cancels the pending one → infinite loop
staticData.roles / featureFlag mirror the gate for nav only The nav projection filters entries without importing components; the layout/beforeLoad remains the single enforcement point
One <Layout> shell, from _authenticated Page components never wrap themselves in Layout — errors and 404s render in exactly one shell
Subtree gates declared once at the section layout Children inherit by placement; repeating a parent's check per child invites drift
Never gate loaders Loaders resolve data only; flag checks live in beforeLoad, auth in the layouts — one concern, one place

Related

  • Routing — full routing architecture: request flow, 404 split, search state, loaders, nav generation
  • Routes — catalog of route files and URLs
  • frontend/src/routing/auth.ts — computeAuthRedirect / hasAnyRole pure logic
  • frontend/src/routing/redirectStorage.ts — sessionStorage key redirectAfterLogin
  • frontend/src/routing/safeRedirect.ts — getSafeRedirectTarget / isOAuthCallback

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally