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.