Nouvelle app Vite/React independante (workspace apps/*), calquee sur apps/web : port 5174, sa propre image Docker (nginx statique), son propre domaine/deploiement. - AdminApiClient (VITE_ADMIN_API_URL, credentials: include) + ApiError. - AdminAuthContext / RequireAdmin : restaure la session admin via GET /admin/auth/me, garde de route (miroir de AuthContext/RequireAuth). - LoginPage (/login) : validation cliente via adminLoginSchema partage, erreurs traduites via ErrorMessageService. - AdminLayout : sidebar (Tableau de bord / Monitoring / Corrections) + deconnexion, rendu une fois autour du groupe RequireAdmin. - Pages Dashboard / Monitoring / Corrections en placeholder (remplies aux PR 3-5). - i18n fr (bloc admin.* + sous-ensemble errors.*), tokens _theme.scss copies de apps/web (extraction en package partage : suivi separe). - Dockerfile multi-stage (node build -> nginx:alpine) + nginx.conf (SPA fallback). Service admin-web dans docker-compose.yml (port ADMIN_WEB_PORT, VITE_ADMIN_API_URL en build arg). - Cypress : login.feature (KO -> message, OK -> dashboard) + admin-layout .cy.ts (redirection /login sans session, nav entre sections, logout). Job "Run admin-web E2E tests" ajoute a ci.yml. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
96 lines
3.3 KiB
TypeScript
96 lines
3.3 KiB
TypeScript
import {
|
|
type AdminLoginInput,
|
|
type AdminUserView,
|
|
type ApiErrorResponse,
|
|
ErrorCode,
|
|
} from "@batch-cooking/shared";
|
|
|
|
/**
|
|
* Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL`
|
|
* (see `.env.example`). Defaults to `""` (same origin) — correct behind a
|
|
* shared reverse proxy; native dev overrides it to `http://localhost:3000`
|
|
* in `apps/admin-web/.env` since the Vite dev server (5174) and the API
|
|
* (3000) are different origins.
|
|
*/
|
|
const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_API_URL ?? "";
|
|
|
|
/**
|
|
* Thrown by {@link AdminApiClient} on any non-2xx response — carries the
|
|
* same {@link ErrorCode} the API returned. Same shape as apps/web's
|
|
* `ApiError`; kept separate rather than shared so the two apps' transport
|
|
* layers stay independent.
|
|
*/
|
|
export class ApiError extends Error {
|
|
public readonly status: number;
|
|
public readonly code: ErrorCode;
|
|
public readonly fieldErrors?: Record<string, string[] | undefined>;
|
|
|
|
public constructor(status: number, body: ApiErrorResponse) {
|
|
super(body.message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.code = body.code;
|
|
this.fieldErrors = body.details;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thin fetch wrapper around the `/admin/*` endpoints — same design as
|
|
* apps/web's `ApiClient` (a class for cohesion/extensibility, one shared
|
|
* stateless instance). Every request sends credentials so the
|
|
* `admin_session` httpOnly cookie round-trips.
|
|
*/
|
|
export class AdminApiClient {
|
|
/**
|
|
* Performs a JSON request against the admin API and returns the parsed body.
|
|
*
|
|
* @throws {ApiError} if the response status is not in the 2xx range.
|
|
*/
|
|
private async _request<TResponseBody>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
): Promise<TResponseBody> {
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, {
|
|
...options,
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...options.headers },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
|
throw new ApiError(
|
|
response.status,
|
|
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
|
);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as TResponseBody;
|
|
}
|
|
return (await response.json()) as TResponseBody;
|
|
} catch (err) {
|
|
// Rethrown as-is — callers surface it their own way; this is just the
|
|
// one place the fetch/`await` sits in a try/catch per the repo's rule.
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Verifies admin credentials and starts an admin session. */
|
|
public login(input: AdminLoginInput): Promise<AdminUserView> {
|
|
return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) });
|
|
}
|
|
|
|
/** Ends the current admin session. */
|
|
public logout(): Promise<void> {
|
|
return this._request("/admin/auth/logout", { method: "POST" });
|
|
}
|
|
|
|
/** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
|
public me(): Promise<AdminUserView> {
|
|
return this._request("/admin/auth/me");
|
|
}
|
|
}
|
|
|
|
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
|
|
export const adminApiClient = new AdminApiClient();
|