Skip to main content

API layer

All ERP HTTP traffic flows through one axios instance built in frontend/src/api/client.ts; the customer portal has its own smaller client (below). Endpoint semantics (pagination, error shape, auth header) are documented API-side in ../api/conventions.md.

client.ts anatomy

Base URL

export const API_BASE_URL = import.meta.env.VITE_API_URL ?? '';

VITE_API_URL defaults to empty, so requests use relative URLs (/api/employees) and go to the same origin — in Docker that's nginx, which reverse-proxies /api/ and /ws to the backend, so there are no CORS hops in normal use. The value is baked in at image build time; for the host-HMR loop you set it to http://localhost:8081 instead (see README.md and ../onboarding.md).

Custom paramsSerializer

The instance serializes query params itself: arrays are appended as repeated keys (sort=a&sort=b — what Spring expects for multi-sort), scalars are stringified, and anything else (objects, null, undefined) is silently dropped. That last part matters for grid pages, which build param objects with many optional filter fields.

Request interceptor

Reads store.getState().auth.token — the Redux store imported directly, no React involved (state.md explains why auth lives in Redux) — and sets Authorization: Bearer <token> when present.

Response interceptor: the silent-refresh 401 flow

Mechanics worth knowing, all visible in client.ts:

  • _retried once-per-request flag. Each failed request is retried at most once after a refresh; a second 401 (refresh raced a revocation, permissions changed) falls through to logout instead of looping.
  • One shared in-flight refreshPromise. As the code comment explains: refresh tokens are single-use and rotated on every call, with reuse treated as theft by the backend's RefreshTokenService (family reuse-detection). If several requests 401ed simultaneously and each called refresh independently, the second call would present an already-used token — indistinguishable from a replay attack — and the backend would kill the whole token family, logging the user out. Serializing every concurrent 401 through a single promise (refreshPromise ??= …, cleared in finally) makes bursts safe. Backend side: ../security/authentication.md.
  • Logout on refresh failure. If there is no refresh token or the refresh call fails, the interceptor dispatches logout(), which clears state and the motorph.auth localStorage entry; ProtectedRoute then bounces to /login.
  • Auth endpoints are exempt. /api/auth/login, /refresh and /logout never trigger refresh-and-retry — they carry their own credential (or none), and a 401 from login just means "wrong password".

Error handling

The backend's error body is ApiErrorResponse { message, status, time } (declared in api/types.ts; shape documented in ../api/conventions.md). api/errors.ts exports the one helper everyone uses:

getApiErrorMessage(error, 'Failed to save employee')

It returns the server's message when the error is an axios error carrying one, else the fallback. The convention is to feed the result to a toast in mutation onError handlers — see forms.md for the pattern in situ.

The api/ module convention

frontend/src/api/ holds ~60 modules, one per backend domain (employees.ts, payroll.ts, job-requisitions.ts, …). Each is deliberately thin:

  • exports typed request/param interfaces and calls apiClient with typed responses — DTO types live in the module or in api/types.ts;
  • no caching, state, or React imports — that's the job of the hooks/api/ React Query layer (state.md) or the grid's fetchPage (ag-grid.md);
  • one function per endpoint, named after the operation (listEmployees, createEmployee, updateEmployeeStatus).

api/employees.ts is a good reference — including how AG-Grid-style filter params (lastNameSearch, hireDateFrom/To, fields projection) are expressed as a typed params object.

The portal client

The customer portal uses a separate instance, api/portalClient.ts: same API_BASE_URL, but its request interceptor reads the token from the Zustand usePortalAuthStore (not Redux), and its response interceptor does no refresh at all — on any 401 it logs the portal session out and hard-redirects to /portal/login (the portal token has no refresh rotation; see ../api/auth.md).

Known wart — a three-file import cycle. api/portal.ts imports portalApiClient from portalClient.ts; portalClient.ts imports usePortalAuthStore from store/portalAuthStore.ts; and portalAuthStore.ts imports the PortalUser type back from api/portal.ts. The closing edge is type-only, so nothing breaks at runtime, but the cycle is real in the module graph and is a standing refactor candidate: moving PortalUser into a shared types module would break it.