Production QA reported "Submit failed 413" on /generation and plain "Upload failed" (no detail) on resource upload. Root cause is the outer reverse-proxy nginx on the VPS — still on default `client_max_body_size 1m` — rejecting requests before they reach Odoo. The inner docker-nginx and Odoo limits were already raised in earlier commits; only the VPS proxy was left unpatched. UI side - Add `describeApiError(err, fallback, context)` helper in lib/api-client.ts. Maps common HTTP codes to human-readable, actionable toast descriptions (413 → "ask ops to raise nginx client_max_body_size", 504 → "server took too long, retry", 502 → "Odoo is restarting", 401 → "sign in again", 4xx/5xx → server error body + status code). - Use it in GenerationPage submit, ResourceManager upload, TeacherLibrary upload, CustomExamCreate save/publish. Replaces four slightly-different ad-hoc mappings with one source of truth. Deploy side - Add deploy/nginx-vps.conf.example: full TLS reverse-proxy template with the body/timeout knobs that must match Odoo's limit_request (128 MB) and limit_time_real (900 s). - Add docs/DEPLOY_413_504_FIX.md: step-by-step remediation guide for the team lead covering the 3 layers that can block large bodies (outer nginx, Cloudflare, Odoo worker) and how to verify each. Made-with: Cursor
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
/**
|
|
* 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<boolean> | null = null;
|
|
|
|
async function performRefresh(): Promise<boolean> {
|
|
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<boolean> {
|
|
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<string, string>, withJson = true): Record<string, string> {
|
|
const headers: Record<string, string> = { ...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<string | number | boolean>;
|
|
|
|
/**
|
|
* Accept any object-shaped bag of query params. We intentionally use `object`
|
|
* here rather than `Record<string, QueryParamValue>` 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<string, unknown>)) {
|
|
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<T>(response: Response): Promise<T> {
|
|
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<T>(url: string, init: RequestInitWithSkip): Promise<T> {
|
|
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<string, string>),
|
|
Authorization: `Bearer ${getAccessToken()}`,
|
|
},
|
|
};
|
|
return performRequest<T>(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<T>(response);
|
|
}
|
|
|
|
export const api = {
|
|
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
|
return performRequest<T>(buildUrl(path, params), {
|
|
method: "GET",
|
|
headers: buildHeaders(),
|
|
});
|
|
},
|
|
|
|
async post<T>(path: string, body?: unknown): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "POST",
|
|
headers: buildHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
},
|
|
|
|
async patch<T>(path: string, body?: unknown): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "PATCH",
|
|
headers: buildHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
},
|
|
|
|
async put<T>(path: string, body?: unknown): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "PUT",
|
|
headers: buildHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
},
|
|
|
|
async delete<T>(path: string): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "DELETE",
|
|
headers: buildHeaders(),
|
|
});
|
|
},
|
|
|
|
async upload<T>(path: string, formData: FormData): Promise<T> {
|
|
return performRequest<T>(buildUrl(path), {
|
|
method: "POST",
|
|
headers: buildHeaders(undefined, false),
|
|
body: formData,
|
|
});
|
|
},
|
|
};
|