Skip to main content

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 ?? /dashboardstate.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 by ROLE_DISPLAY_ORDER. This is derived from permissions, not from user.roles — a user qualifies for a role view iff they could exercise all of it.
  • effectivePermissions — the lens itself. With activeRole === null it 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 against effectivePermissions; used by route guards, nav filtering, and per-button gating.
  • switchRole(roleName | null) — dispatches setActiveRole (persisted).

Route guards

  • ProtectedRoute — renders <Outlet/> when a token exists, else redirects to /login with state.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/success is registered in App.tsx outside any RequirePermission, with a comment explaining why: it's where the payment provider drops the customer after checkout, and a silent permission-redirect to /dashboard would swallow the checkout return. ProtectedRoute still 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.