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:
_retriedonce-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'sRefreshTokenService(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 infinally) 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 themotorph.authlocalStorage entry;ProtectedRoutethen bounces to/login. - Auth endpoints are exempt.
/api/auth/login,/refreshand/logoutnever 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
apiClientwith typed responses — DTO types live in the module or inapi/types.ts; - no caching, state, or React imports — that's the job of the
hooks/api/React Query layer (state.md) or the grid'sfetchPage(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.