Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
the project-manager PNG in a separate commit).
Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.
Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.
Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
smoke tests, npm scripts test:e2e / test:e2e:install.
Made-with: Cursor
269 lines
8.4 KiB
TypeScript
269 lines
8.4 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";
|
|
}
|
|
}
|
|
|
|
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 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}`;
|
|
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,
|
|
});
|
|
},
|
|
};
|