Auth and permissions (client side)
How the frontend logs in, remembers the session, and decides what to render. Keep one principle in mind throughout, because it explains every design choice on this page:
The backend authorizes every request against the union of the user's permissions, resolved from the RBAC tables — the frontend never sends its "active role" anywhere. Everything the client does with permissions is a UI lens: it can under-show (hide pages/buttons the user could actually use) but it can never over-grant (a request for something the user lacks still gets a backend 403). Hiding UI is UX, not security (../architecture.md §4, ../security/authentication.md).
Login flow
pages/auth/Login.tsx runs a React
Query mutation around api/auth.ts's
login() (which attaches the ALTCHA proof-of-work payload when the feature is
enabled). On success it dispatches
setCredentials({ token: accessToken, refreshToken, user }) to the Redux auth
slice, then navigates to location.state.from ?? /dashboard — state.from is
planted by ProtectedRoute when it bounced an unauthenticated deep link, so you
land where you were headed. On a 428 it resets the ALTCHA widget (the payload is
single-use); 423/429 surface the backend's lockout/rate-limit message verbatim.
Storage and silent refresh
The whole session — token, refreshToken, user, activeRole — persists to
localStorage under motorph.auth
(redux/auth.ts) and is rehydrated on page
load. Rehydration re-validates the persisted activeRole with the same
subset rule as availableRoles below, so a role view the user no longer
qualifies for doesn't survive a reload. Access-token expiry is handled
transparently by the axios 401/refresh interceptor —
api-layer.md.
Logout (performLogout) revokes the refresh
token server-side best-effort, then clears local state either way.
useAuth — the one hook everything reads
hooks/useAuth.ts derives everything
from the auth slice plus the static role map in
constants/role-permissions.ts:
availableRoles— the roles whose full static permission set the user actually holds (ROLE_PERMISSIONS[role].every(p => userPerms.has(p))), ordered byROLE_DISPLAY_ORDER. This is derived from permissions, not fromuser.roles— a user qualifies for a role view iff they could exercise all of it.effectivePermissions— the lens itself. WithactiveRole === nullit is the user's full permission union; with a role selected it is the intersection of the user's permissions with that role's static set. Being an intersection is what guarantees under-show-never-over-grant.hasPermission(p)— membership test againsteffectivePermissions; used by route guards, nav filtering, and per-button gating.switchRole(roleName | null)— dispatchessetActiveRole(persisted).
Route guards
ProtectedRoute— renders<Outlet/>when a token exists, else redirects to/loginwithstate.from = pathname + search.RequirePermission— takes a single permission or an array (array = OR:permissions.some(hasPermission)). On failure it silently redirects to/dashboard— no error page, the app just behaves as if the URL didn't exist.- The deliberate exception:
/billing/successis registered inApp.tsxoutside anyRequirePermission, with a comment explaining why: it's where the payment provider drops the customer after checkout, and a silent permission-redirect to/dashboardwould swallow the checkout return.ProtectedRoutestill requires a login.
The active-role switcher
A user with several roles (or a System Administrator, who qualifies for all of
them) can scope the UI to one role at a time. activeRole is client-side
only — switching roles changes which nav items and routes render, and nothing
about what the backend would authorize.
Auto-select on first render
(ui/layout/app-layout.tsx):
when a user lands with activeRole === null, the layout picks
availableRoles.find(r => r !== 'Employee') ?? availableRoles[0] — a
multi-role user should see their admin view, not the lowest-common one. The
carve-out, straight from the code comment: System Administrators stay at
null ("Full Access"). They qualify for every role, so order-based
auto-select would land them on HR Administrator and hide sysadmin-only pages
like Billing behind a lens they'd have to notice and undo.
The switcher UI
(ui/layout/profile-menu.tsx)
renders when availableRoles.length > 1: a radio group of role views, plus a
"Full Access (All Roles)" entry for sysadmins that maps back to
switchRole(null). Switching navigates to /dashboard, since the current page
may not exist under the new lens.
The E2E helper
e2e/helpers/serverGrid.ts documents the
practical consequence in its login() docstring: the payroll_demo persona
holds HR Administrator and Payroll Administrator, and left to auto-selection
every test would land on the HR view with the Payroll nav hidden entirely —
tests must pin activeRole per persona. Same session, same backend rights,
different lens.
Keeping role-permissions.ts in sync
ROLE_PERMISSIONS is a static, hand-maintained map — the frontend never
fetches role definitions at runtime (the login response carries the user's
permission list, but role→permission composition lives in this file). When a
Flyway migration grants a role new permissions, the matching constants must be
added to constants/permissions.ts
and to that role's entry in
constants/role-permissions.ts,
or the intersection silently drops them: nav items and routes vanish for users
in that role lens even though the backend would happily authorize the calls.
This has bitten before — see the post-mortem in
../troubleshooting.md.
Portal auth is a separate system
The customer portal shares none of the above: its state lives in the Zustand
portalAuthStore (persisted as
motorph.portal.auth), its token is a separate 24-hour portal JWT with no
refresh rotation, its guard is
PortalProtectedRoute,
and a 401 simply logs the portal session out
(api-layer.md). There are no roles or
permissions on the portal side. Endpoint details:
../api/auth.md.