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:
Yamen Ahmad
2026-04-19 14:16:32 +04:00
parent 9b20d6ce66
commit b02c2e7526
75 changed files with 3727 additions and 551 deletions

View File

@@ -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);
},
};