Architecture
This page covers app boot, routing, the auth system, shared HTTP infrastructure, UI preference contexts, shared components, and the deployment model.
Provider stack (boot order)
src/main.tsx mounts into #root inside React StrictMode. Providers wrap from outermost to innermost:
The router is created with:
routeTree— auto-generated atsrc/routeTree.gen.tsby the@tanstack/router-pluginVite plugincontext: { queryClient }— makes the query client available to routeloaderfunctionsdefaultPreload: 'intent'— preloads route chunks on hover/focusdefaultPreloadStaleTime: 0
Routing
The app uses TanStack Router file-based routing. Route files live in src/routes/; the route tree is auto-generated into src/routeTree.gen.ts on every build/dev start.
Route tree overview
Key routing conventions:
_authenticatedis a pathless layout segment (leading underscore = contributes no URL path). Itsroute.tsxwraps all children inRequireAuth.(auth)and(errors)are route groups (parentheses = no URL prefix, affects file organization only).__root.tsxcreates the root route withcreateRootRouteWithContext<{ queryClient: QueryClient }>(), enabling loaders to callqueryClient.ensureQueryData.__root.tsxrenders<NavigationProgress />,<Outlet />,<Toaster />(sonner), and dev-only devtools.- Auto code splitting is enabled (
autoCodeSplitting: trueinvite.config.ts) — each route is a lazy-loaded chunk.
Navigation items and permissions
The sidebar navigation is defined statically in src/components/layout/data/sidebar-data.ts. Each nav item carries a permission (single, AND) or anyPermission (array, OR) guard. filterNavByPermission() in src/components/layout/filter-nav.ts prunes the nav tree at render time based on the current operator's permissions. firstPermittedPath() resolves where / redirects after login.
Auth system
OTP email login flow
-
Request OTP —
POST /v1/ops/auth/request-otpwith{ email }. Uses rawfetch(notapiFetch) to avoid the auth interceptor. Server always returns 200 (anti-enumeration). Source:src/auth/authApi.ts. -
Verify OTP —
POST /v1/ops/auth/verify-otpwith{ email, code }. Returns{ access, refresh }(validated asTokenPair). -
Store tokens —
src/lib/tokenStore.ts:- Access token: in-memory only (
let accessTokenmodule variable — never written to storage) - Refresh token:
localStoragekeysalon_admin_refresh - Email:
localStoragekeysalon_admin_email
- Access token: in-memory only (
-
Load identity —
GET /v1/ops/meviaapiFetch. Returns{ email, displayName?, roles[], permissions[] }. Stored inAuthContextstate. Source:src/auth/meApi.ts.
Session restore on page load
AuthContext (src/auth/AuthContext.tsx) runs a useEffect on mount:
- Reads refresh token from
localStorage. - If present:
POST /v1/ops/auth/refresh→ newTokenPair→setPair+fetchMe→ status'authenticated'. - If missing or refresh fails:
clear()→ status'unauthenticated'.
Silent token refresh (401 handling)
src/lib/httpClient.ts wraps every authenticated call:
- On 401: calls
refreshOnce()— a singleton in-flight promise so concurrent callers share one refresh request — then retries the original request once with the new access token. - On 403: emits
onForbiddenevent →AuthContextre-fetches/v1/ops/meto resync the permission snapshot. - If refresh itself fails: emits
onSessionDead→AuthContextclears all state → status'unauthenticated'(user sees login form in-place).
Route protection
src/routes/_authenticated/route.tsx renders <RequireAuth> around the layout:
loading→ centered spinnerunauthenticated→ renders<LoginPage />in-place (not a redirect; replaces the child with the login form)authenticated→ renders<AuthenticatedLayout />
Permission checking
src/auth/RequirePermission.tsx and the can() helper from useAuth():
can(action)returnstrueifroles.includes('admin')(wildcard) orpermissions.includes(action).<RequirePermission permission="resource.action">renders a<ForbiddenError />fallback (overridable viafallbackprop) when the check fails.- This is UI-only enforcement — the backend enforces independently.
Logout
POST /v1/ops/auth/logout via apiFetch (best-effort), then clear() wipes the access token from memory and refresh from localStorage. Source: src/auth/authApi.ts.
HTTP client and shared infrastructure
src/lib/httpClient.ts
The central authenticated fetch wrapper used by all feature API modules.
apiFetch<T>(path, options?)— addsAuthorization: Bearer <access>header, auto-retries once on 401, parses JSON, throwsApiErroron non-ok responses.apiFetchRaw(path, options?)— same as above but returns the rawResponse(used for binary downloads).- Emits DOM custom events:
onSessionDead,onTokensRotated,onForbidden. - Never use raw
fetchfor authenticated calls. Always useapiFetch.
src/lib/tokenStore.ts
Manages the token pair:
setPair({ access, refresh })— stores access in memory, refresh inlocalStorage.getAccess()/getRefresh()— reads from the respective locations.clear()— wipes both.
src/lib/queryClient.ts
Global QueryClient shared across the app:
staleTime: 30_000(30s default)refetchOnWindowFocusonly in production- Retries only on
retryableApiErrors (502upstream_error), capped at 2 attempts
src/lib/config.ts
apiUrl(path)— prependsVITE_API_BASE_URLbookingUrl(slug)— constructsVITE_BOOKING_BASE_URL/<slug>
src/lib/errorMap.ts
normalizeError(status, body) maps { ok: false, error: "<code>" } responses to operator-friendly message strings for ~25 known error codes (e.g. store_not_found, duplicate_email, invalid_transition).
UI preference contexts
All contexts persist preferences in cookies; they wrap the router in the provider stack.
These preferences are exposed to users via the Config Drawer (src/components/config-drawer.tsx) — a slide-in settings sheet accessible from the page header, with per-section reset controls and a global reset.
Shared components
Layout shell (src/components/layout/)
Data table system (src/components/data-table/)
A reusable TanStack Table UI kit. The pattern is URL-state-driven:
useTableUrlState (src/hooks/use-table-url-state.ts) bridges TanStack Router search params ↔ TanStack Table state. It supports typed column filters ('string' for text, 'array' for multi-select), custom serialize/deserialize, and an ensurePageInRange() guard.
Other notable shared components
Zustand store (legacy)
src/stores/auth-store.ts defines useAuthStore, which stores user info and an access token in a cookie (cookie name: 'thisisjustarandomstring'). This store is not used by the active auth flow. The live system uses AuthContext + tokenStore.ts. The store is a leftover from an earlier implementation and can be treated as dead code.
Deployment
The app is a static SPA deployed to Cloudflare Workers via wrangler. Configuration: wrangler.jsonc.
Key wrangler settings:
assets.not_found_handling: single-page-application— SPA fallback routing (all 404s serveindex.html)compatibility_flags: [nodejs_compat]observability.enabled: true
A netlify.toml also exists, suggesting Netlify was a previous or considered alternative deploy target.
There is no CI/CD pipeline — deploys are currently run manually from a developer machine.
Changing this area
- Routing changes: Edit files in
src/routes/. The route tree auto-regenerates. Do not editsrc/routeTree.gen.tsby hand. - Auth flow changes: Touch
src/auth/AuthContext.tsx,src/auth/authApi.ts, andsrc/lib/httpClient.ts. Update tests insrc/test/auth/andsrc/test/lib/httpClient*.test.ts. - New env var: Add to
.env.example,.env.development,.env.uat,.env.production, andsrc/lib/config.ts. Never commit secret values. - Nav + permissions: Update
src/components/layout/data/sidebar-data.tsand ensure the permission string exists in the server-side permission catalog.