/** * 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"; } } 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 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}`; 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, }); }, };