Skip to content

Frontend Routing

Every URL the SPA serves, the page component behind it, and the permission (or role) that gates it. Authoritative source is src/App.tsx.

The table below is the April route set — these were added since

/app now renders the role dashboard, chosen from the viewer's permissions, not one shared dashboard (Role dashboards). New routes: /customers/:id (Customer 360, customers.view), /operations/journey (bookings.view), /operations/incidents (Duty of care, incidents.view), /inventory/holds, /inventory/releases, /inventory/deadlines (Holds, deadlines and releases, all inventory.view), /config/passenger-categories (admin.config.edit), /field, /field/groups/:id and /field/groups/:id/check-in (Tour leader app, field.view / field.checkin, own shell without the sidebar), /m, /m/bookings, /m/bookings/:id, /m/approvals, /m/customers, /m/groups, /m/more, /m/notifications (Phone app, staff roles, each gated on the desktop screen's permission; /app sends a phone or the Android app there), and the public /visa/apply (Visa). Old /sales/customers?id= links redirect to Customer 360. A staff user who lacks a route's permission now gets an explained 403 page rather than a silent redirect.

Routing mechanics

  • Router: react-router-dom v6, wrapped in <BrowserRouter> with the v7 future flags v7_startTransition and v7_relativeSplatPath enabled (src/App.tsx:128-133).
  • Page components are lazy imports (lazyWithPreload, which is React.lazy plus a preload()), wrapped in <Suspense fallback={<RouteSkeleton />}> for bundle splitting. Each static path is registered with registerRoutePreloads in src/App.tsx.
  • A screen's code starts downloading when its sidebar link is hovered or focused, and a few of the person's own screens are fetched once the browser is idle. Nothing is fetched ahead for the public site or for screens the person cannot open. See Performance: what loads when (PRF-005).
  • Gating uses <ProtectedRoute> — see Permissions system. Combines allowedRoles (role filter) and requiredPermissions (permission AND-check).
// src/App.tsx:149
<Route
  path="/app"
  element={<ProtectedRoute allowedRoles={['admin', 'staff']}><Index /></ProtectedRoute>}
/>

Public routes

No auth required.

Route Component Gating
/ Home —
/privacy Privacy —
/terms Terms —
/auth Auth (staff login) —
/reset-password ResetPassword —
/customer/auth CustomerAuth —
/partner/auth PartnerAuth (imported as AgentAuth) —
/pay, /pay/:token PayLink (lazy) — a payment link from the WhatsApp menu (COMM-034) — (the link's token is the proof; razorpay-order checks it)

Staff / back-office routes

All gated by <ProtectedRoute requiredPermissions={[...]}> unless noted.

Dashboard

Route Component Permissions / roles
/app Index allowedRoles=['admin','staff'] (note: currently role-gated, not permission-gated — dashboard.view is seeded but not wired to this route; docs/PERMISSIONS.md §8.2, §8.8)

Inventory

Route Component Permissions
/inventory/quota AirlineBlocks inventory.view
/inventory/quota/:id AirlineBlockDetailPage inventory.view
/inventory/fit FITInventory inventory.view
/inventory/fit/:id FITDetailPage inventory.view
/inventory/b2b-flights B2BFlights inventory.view
/inventory/ground-transfers GroundTransfers inventory.view

Groups

Route Component Permissions
/groups Groups groups.view
/groups/:id GroupDetailPage groups.view

Sales

Route Component Permissions
/sales/customers Customers customers.view
/sales/leads Leads leads.view
/sales/quotations Quotations quotations.view
/sales/bookings Bookings bookings.view
/sales/bookings/:id BookingDetail bookings.view
/sales/bookings/new BookingWizard bookings.create

Partners, customers, suppliers, hotels

Route Component Permissions
/agents Partners (imported as Agents) agents.view
/people People customers.view
/people/employees Employees admin.view
/people/account-deletions AccountDeletions customers.view (the list itself: customers.edit / partners.edit per kind — AUD-027)
/suppliers Suppliers inventory.view (drift — should be suppliers.view; docs/PERMISSIONS.md §8.5)
/suppliers/:id SupplierDetailPage inventory.view
/hotels Hotels hotels.view

Workflow (approvals, corrections, visa, tickets)

Route Component Permissions
/approvals Approvals approvals.view
/corrections CorrectionsQueue bookings.view (drift — should be approvals.view; docs/PERMISSIONS.md §8.3)
/visa VisaPipeline visa.view
/visa/:id VisaCaseDetail visa.view
/tickets Tickets tickets.view
/tickets/:id TicketDetail tickets.view

Finance & reporting

Route Component Permissions / notes
/accounts — Redirect to /finance (<Navigate to="/finance" replace />)
/finance Finance finance.view
/reports Reports reports.view
/requests Requests requests.view

Communications & internal

Route Component Permissions
/communications Communications customers.view (drift — future communications.view; docs/PERMISSIONS.md §8.4)
/whatsapp WhatsAppInbox customers.view (same drift)
/internal/chat Chat (imported as ChatPage) chat.view

Admin

Route Component Permissions
/admin Admin allowedRoles=['admin'] AND requiredPermissions=['admin.view'] (double-gated)

Settings (mixed audience)

Route Component Permissions
/settings/security SecuritySettings allowedRoles=['admin','staff','agent','customer'] — available to every authenticated role
/me MyProfile (src/pages/settings/MyProfile.tsx) allowedRoles=['admin','staff','agent'] — the person's own profile (ACC-071); travellers have My details in their portal

Customer portal

Role-gated; actions inside are ownership-scoped by the API (docs/PERMISSIONS.md §4.5, §6.10).

Route Component Gating
/customer CustomerPortal allowedRoles=['customer']

Partner (B2B agent) portal

Role-gated; actions inside are ownership-scoped (docs/PERMISSIONS.md §4.5, §6.11).

Route Component Gating
/partner PartnerPortal (imported as AgentPortal) allowedRoles=['agent']
/partner/bookings PartnerBookings (imported as AgentBookings) allowedRoles=['agent']
/partner/bookings/new PartnerBookingWizard allowedRoles=['agent']
/partner/invoices PartnerInvoices (imported as AgentInvoices) allowedRoles=['agent']
/partner/inventory PartnerInventory allowedRoles=['agent']
/partner/profile <Navigate to="/me" /> redirect — the partner's profile is the shared /me page since 2026-09-25
/partner/reports PartnerReports allowedRoles=['agent']
/partner/payments PartnerPayments allowedRoles=['agent']
/partner/flights PartnerFlights (imported as AgentFlights) allowedRoles=['agent']

Fallback

Route Component Notes
* NotFound Catch-all

Cross-cutting behaviour

  • Facebook Pixel — FacebookPixelTracker (src/App.tsx:104) fires PageView only on public marketing paths (/, /privacy, /terms). Authenticated portals are deliberately excluded (privacy policy).
  • ErrorBoundary — Every route is wrapped (src/App.tsx:135) so a crash in one page doesn't take down the shell.
  • Skip-to-content — SkipToContent + <main id="main-content" tabIndex={-1}> for keyboard users.
  • PWA update prompt — PWAUpdateNotifier (src/App.tsx:127) surfaces service-worker update notices.
  • React Query defaults — staleTime: 5m, gcTime: 10m, refetchOnWindowFocus: false, retry: 1 (src/App.tsx:84-98).

Drift tracked against the matrix

Items where the route-level gate and the permissions matrix don't fully agree. Each is listed in docs/PERMISSIONS.md §8:

  • /app uses allowedRoles rather than a permission. The dashboard chooses its own content from the viewer's permissions and every widget is permission-checked in the database, so the route gate is only "is this a staff user".
  • /corrections uses bookings.view instead of an approvals.view / dedicated corrections.view (§8.3).
  • /communications and /whatsapp use customers.view instead of communications.view (§8.4).
  • /suppliers uses inventory.view instead of suppliers.view (§8.5).

These are tracked as incremental work; the drift test enforces that the permissions referenced here exist in PERMISSIONS.md and vice-versa.