/** * Odoo REST client with transparent access-token rotation. * * Tokens live in ``localStorage`` under three keys: * - ``encoach_token`` — short-lived access JWT (1h) * - ``encoach_refresh_token`` — long-lived refresh JWT (7d) * - ``encoach_token_exp`` — epoch seconds for the access token * * When a request receives ``401`` *and* a refresh token is present, the client * silently rotates the pair via ``POST /api/auth/refresh`` and retries the * original request exactly once. Multiple concurrent 401s coalesce onto the * same refresh promise so we never fire more than one rotation in flight. * * Motivation: before this change, every expired access token forced a full * re-login, which interrupted long admin dashboards (stats, reports) multiple * times per hour. With the refresh loop, access tokens can be short-lived * (1h) without hurting UX, giving us the security benefit of short access * windows without an eager logout. */ export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, ""); const BASE_URL = API_BASE_URL; export function extractApiErrorMessage(data: unknown, status: number, statusText: string): string { if (data && typeof data === "object" && data !== null && "error" in data) { const msg = (data as { error: unknown }).error; if (msg != null && String(msg).trim()) return String(msg); } return `${status} ${statusText}`; } export class ApiError extends Error { constructor( public status: number, public statusText: string, public data: unknown, ) { super(extractApiErrorMessage(data, status, statusText)); this.name = "ApiError"; } } /** * Turn an arbitrary thrown value from an API mutation into a human-readable, * actionable toast description. Deployment QA kept reporting generic * "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP * status and, when we recognise the code, a hint about what to do next * (payload too big → ask ops to raise nginx, timeout → retry, etc.). * * @param err the error thrown by an `api.*` call (usually an ApiError) * @param fallback text to show if we can't classify the error * @param context short context word used in the 413/504 hints * ("file", "request", "payload" …) */ export function describeApiError( err: unknown, fallback = "Something went wrong", context = "request", ): string { const e = err as { status?: number; message?: string; data?: unknown } | null; const status = e?.status; // Try to extract a server-side error body even if the generic ApiError // message wasn't populated (nginx 413/504 respond with an HTML page so // `data.error` is empty and `message` looks like "413 "). const serverMsg = e?.data && typeof e.data === "object" && "error" in (e.data as object) ? String((e.data as { error: unknown }).error || "").trim() : ""; if (status === 413) { return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`; } if (status === 504) { return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`; } if (status === 502) { return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment."; } if (status === 401) { return "Your session expired — please sign in again."; } if (status === 403) { return serverMsg || "You don't have permission to do that (HTTP 403)."; } if (status === 404) { return serverMsg || "The requested item no longer exists (HTTP 404)."; } if (status === 422 || status === 400) { return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`; } if (status && status >= 500) { return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`; } // Network failure / fetch aborted / CORS — ApiError not thrown at all. if (!status && e?.message) return e.message; return fallback; } const ACCESS_KEY = "encoach_token"; const REFRESH_KEY = "encoach_refresh_token"; const EXP_KEY = "encoach_token_exp"; function getAccessToken(): string | null { return localStorage.getItem(ACCESS_KEY); } function getRefreshToken(): string | null { return localStorage.getItem(REFRESH_KEY); } export function setToken(token: string): void { localStorage.setItem(ACCESS_KEY, token); } export function setRefreshToken(token: string | null | undefined): void { if (token) { localStorage.setItem(REFRESH_KEY, token); } else { localStorage.removeItem(REFRESH_KEY); } } export function setTokenExpiry(epochSeconds: number | null | undefined): void { if (epochSeconds && Number.isFinite(epochSeconds)) { localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds))); } else { localStorage.removeItem(EXP_KEY); } } /** Persist the full token bundle returned by /api/login or /api/auth/refresh. */ export function persistTokenBundle(bundle: { access_token?: string; token?: string; refresh_token?: string; expires_in?: number; }): void { const access = bundle.access_token || bundle.token; if (access) setToken(access); setRefreshToken(bundle.refresh_token || null); if (bundle.expires_in) { setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in); } } export function clearToken(): void { localStorage.removeItem(ACCESS_KEY); localStorage.removeItem(REFRESH_KEY); localStorage.removeItem(EXP_KEY); } // Shared refresh promise — any 401 that arrives while a refresh is in flight // waits on the existing request instead of firing a duplicate. let refreshPromise: Promise | null = null; async function performRefresh(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken) return false; try { const res = await fetch(`${BASE_URL}/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!res.ok) return false; const data: { access_token?: string; token?: string; refresh_token?: string; expires_in?: number; } = await res.json().catch(() => ({} as never)); if (!data.access_token && !data.token) return false; persistTokenBundle(data); return true; } catch { return false; } } function refreshOnce(): Promise { if (!refreshPromise) { refreshPromise = performRefresh().finally(() => { refreshPromise = null; }); } return refreshPromise; } function getCurrentLanguage(): string { // Mirrors the i18n bootstrap in src/i18n/index.ts: the user's explicit // pick wins; everything else falls back to English. We intentionally do // not consult navigator.language here so AI-generated content stays in // English on first visit (matching the UI default) until the user flips // the language toggle. try { const stored = localStorage.getItem("encoach-lang"); if (stored) return stored; } catch { // localStorage unavailable (SSR, sandboxing, etc.) } return "en"; } function buildHeaders(extra?: Record, withJson = true): Record { const headers: Record = { ...extra }; if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json"; const token = getAccessToken(); if (token) headers["Authorization"] = `Bearer ${token}`; // Tell the backend which UI language the user is on so AI-generated content // (coaching tips, insights, alerts, narratives) can be returned in the same // language instead of always defaulting to English. if (!headers["Accept-Language"]) { headers["Accept-Language"] = getCurrentLanguage(); } return headers; } /** * Query param values we know how to serialise into a URL. Arrays are joined * with commas because that's what our Odoo controllers expect for multi-value * filters (e.g. `?state=draft,confirmed`). */ export type QueryParamValue = | string | number | boolean | null | undefined | Array; /** * Accept any object-shaped bag of query params. We intentionally use `object` * here rather than `Record` because typed interfaces * (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by * default, which would force every call-site to cast. */ export type QueryParams = object; function buildUrl(path: string, params?: QueryParams): string { const url = new URL(`${BASE_URL}${path}`, window.location.origin); if (params) { for (const [key, rawValue] of Object.entries(params as Record)) { if (rawValue === undefined || rawValue === null) continue; if (Array.isArray(rawValue)) { if (rawValue.length === 0) continue; url.searchParams.set(key, rawValue.map(v => String(v)).join(",")); continue; } url.searchParams.set(key, String(rawValue)); } } return url.toString(); } async function parseResponse(response: Response): Promise { const data = await response.json().catch(() => null); if (!response.ok) throw new ApiError(response.status, response.statusText, data); return data as T; } type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean }; async function performRequest(url: string, init: RequestInitWithSkip): Promise { const response = await fetch(url, init); // Auth/refresh endpoints opt out of the retry loop — otherwise a bad // refresh token would recurse forever. const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login"); if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) { const hadRefresh = !!getRefreshToken(); if (hadRefresh) { const rotated = await refreshOnce(); if (rotated) { // Rebuild headers so the new access token is attached. const newInit: RequestInitWithSkip = { ...init, _skipRetry: true, headers: { ...(init.headers as Record), Authorization: `Bearer ${getAccessToken()}`, }, }; return performRequest(url, newInit); } } const hadAccess = !!getAccessToken(); clearToken(); if (hadAccess || hadRefresh) { window.location.href = "/login"; } throw new ApiError(401, response.statusText, await response.json().catch(() => null)); } return parseResponse(response); } export const api = { async get(path: string, params?: QueryParams): Promise { return performRequest(buildUrl(path, params), { method: "GET", headers: buildHeaders(), }); }, async post(path: string, body?: unknown): Promise { return performRequest(buildUrl(path), { method: "POST", headers: buildHeaders(), body: body ? JSON.stringify(body) : undefined, }); }, async patch(path: string, body?: unknown): Promise { return performRequest(buildUrl(path), { method: "PATCH", headers: buildHeaders(), body: body ? JSON.stringify(body) : undefined, }); }, async put(path: string, body?: unknown): Promise { return performRequest(buildUrl(path), { method: "PUT", headers: buildHeaders(), body: body ? JSON.stringify(body) : undefined, }); }, async delete(path: string): Promise { return performRequest(buildUrl(path), { method: "DELETE", headers: buildHeaders(), }); }, async upload(path: string, formData: FormData): Promise { return performRequest(buildUrl(path), { method: "POST", headers: buildHeaders(undefined, false), body: formData, }); }, };