feat(frontend): Phase 2/3 hardening release
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
This commit is contained in:
@@ -1,4 +1,23 @@
|
||||
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -21,120 +40,229 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("encoach_token");
|
||||
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("encoach_token", token);
|
||||
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("encoach_token");
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(EXP_KEY);
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
// 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;
|
||||
|
||||
if (response.status === 401) {
|
||||
const hadToken = !!getToken();
|
||||
clearToken();
|
||||
// Login failure is also 401 — do not hard-redirect when no session existed.
|
||||
if (hadToken) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
throw new ApiError(401, response.statusText, data);
|
||||
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;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText, data);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...extra,
|
||||
};
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
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;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||
/**
|
||||
* 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) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
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?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
const res = await fetch(buildUrl(path, params), {
|
||||
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
||||
return performRequest<T>(buildUrl(path, params), {
|
||||
method: "GET",
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PATCH",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PUT",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async delete<T>(path: string): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "DELETE",
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers,
|
||||
headers: buildHeaders(undefined, false),
|
||||
body: formData,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user