import type { AdminEnvironment, AdminPlatform, AdminProject, AdminProjectList, AdminUser, AdminUserList, ApiKeyList, EnvironmentList, PlatformList, CreatedApiKey, CurrentUser, InvitationPreview, MemberList, ProjectRole, GridRow, KeyRow, NamespaceTree, Page, Project, ProjectStats, TranslationEntry, TranslationStatus, } from './types'; /** * Client HTTP de la Management API. * * Aucune bibliothèque : `fetch` suffit, et l'API étant servie en même origine, * le cookie de session part tout seul. La seule règle à ne pas oublier est * `credentials: 'same-origin'`, sans quoi le navigateur n'envoie rien. */ export class ApiError extends Error { constructor( public readonly status: number, message: string, public readonly detail?: string, ) { super(message); this.name = 'ApiError'; } /** L'utilisateur n'est plus authentifié : sa session a expiré. */ get isUnauthenticated(): boolean { return this.status === 401; } /** Rejet de validation — le message est destiné à être lu tel quel. */ get isValidation(): boolean { return this.status === 422; } /** Quelqu'un d'autre a modifié la traduction entre-temps. */ get isConflict(): boolean { return this.status === 409; } } async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(path, { ...init, credentials: 'same-origin', headers: { Accept: 'application/ld+json', ...init.headers, }, }); if (response.status === 204) { return undefined as T; } const text = await response.text(); const payload = text ? safeParse(text) : null; if (!response.ok) { // API Platform renvoie `detail` ; nos contrôleurs aussi. C'est le champ // rédigé pour un humain, celui qu'on affiche sans le reformuler. const detail = (payload && typeof payload === 'object' && 'detail' in payload ? String((payload as { detail: unknown }).detail) : undefined) ?? response.statusText; throw new ApiError(response.status, detail, detail); } return payload as T; } function safeParse(text: string): unknown { try { return JSON.parse(text); } catch { return null; } } /** * Déplie une collection Hydra. * * JSON-LD est retenu précisément pour `totalItems` : la grille virtualisée en a * besoin pour dimensionner sa zone de défilement sans avoir tout chargé. */ function toPage(payload: { member?: T[]; totalItems?: number }): Page { return { items: payload.member ?? [], total: payload.totalItems ?? 0 }; } export interface GridQuery { projectUuid: string; locale: string; platform?: string; status?: TranslationStatus; namespace?: string; q?: string; unassigned?: boolean; page?: number; itemsPerPage?: number; } export const api = { async me(): Promise { return request('/api/v1/auth/me', { headers: { Accept: 'application/json' } }); }, async login(email: string, password: string): Promise { return request('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ email, password }), }); }, async logout(): Promise { await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'same-origin' }); }, async projects(): Promise { const payload = await request<{ member?: Project[] }>('/api/v1/projects'); return payload.member ?? []; }, async stats(projectUuid: string): Promise { return request(`/api/v1/projects/${projectUuid}/stats`, { headers: { Accept: 'application/json' }, }); }, async namespaces(projectUuid: string, locale: string): Promise { return request( `/api/v1/projects/${projectUuid}/namespaces?locale=${encodeURIComponent(locale)}`, { headers: { Accept: 'application/json' } }, ); }, async grid(query: GridQuery): Promise> { const params = new URLSearchParams({ locale: query.locale }); if (query.platform) params.set('platform', query.platform); if (query.status) params.set('status', query.status); if (query.namespace) params.set('namespace', query.namespace); if (query.q) params.set('q', query.q); if (query.unassigned) params.set('unassigned', '1'); params.set('page', String(query.page ?? 1)); params.set('itemsPerPage', String(query.itemsPerPage ?? 100)); const payload = await request<{ member?: GridRow[]; totalItems?: number }>( `/api/v1/projects/${query.projectUuid}/grid?${params}`, ); return toPage(payload); }, // ── Administration ─────────────────────────────────────────────────── async members(project: string): Promise { return request(`/api/v1/projects/${project}/members`, { headers: { Accept: 'application/json' }, }); }, async invite( project: string, body: { email: string; role: ProjectRole; locales: string[]; isExternal: boolean }, ): Promise<{ email: string; expiresAt: string; message: string }> { return request(`/api/v1/projects/${project}/members/invite`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); }, async updateMember( project: string, memberUuid: string, body: { role?: ProjectRole; locales?: string[] }, ): Promise { await request(`/api/v1/projects/${project}/members/${memberUuid}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); }, async removeMember(project: string, memberUuid: string): Promise { await request(`/api/v1/projects/${project}/members/${memberUuid}`, { method: 'DELETE', headers: { Accept: 'application/json' }, }); }, async apiKeys(project: string): Promise { return request(`/api/v1/projects/${project}/api-keys`, { headers: { Accept: 'application/json' }, }); }, async createApiKey( project: string, body: { name: string; environment: string; scopes: string[] }, ): Promise { return request(`/api/v1/projects/${project}/api-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); }, async revokeApiKey(project: string, keyUuid: string): Promise { await request(`/api/v1/projects/${project}/api-keys/${keyUuid}`, { method: 'DELETE', headers: { Accept: 'application/json' }, }); }, /** * Vue par clé : une clé, toutes ses langues. * * Pas de paramètre `locale` — la réponse les contient toutes. `status` * change de sens au passage : « au moins une langue cible est dans cet * état », et non « la langue affichée l'est ». */ async keys(query: Omit & { focus?: boolean }): Promise> { const params = new URLSearchParams(); if (query.platform) params.set('platform', query.platform); if (query.status) params.set('status', query.status); if (query.namespace) params.set('namespace', query.namespace); if (query.q) params.set('q', query.q); if (query.unassigned) params.set('unassigned', '1'); // Le serveur déduit LUI-MÊME les langues concernées de l'identité // courante : ce drapeau demande une file, il ne la décrit pas. if (query.focus) params.set('focus', '1'); params.set('page', String(query.page ?? 1)); params.set('itemsPerPage', String(query.itemsPerPage ?? 50)); const payload = await request<{ member?: KeyRow[]; totalItems?: number }>( `/api/v1/projects/${query.projectUuid}/keys?${params}`, ); return toPage(payload); }, // ── Plateformes et environnements ──────────────────────────────────── async platforms(project: string): Promise { return request(`/api/v1/projects/${project}/platforms-admin`, { headers: { Accept: 'application/json' }, }); }, async createPlatform( project: string, body: { name: string; slug: string; kind: string; messageFormat: string; exportLayout: string; defaultMaxLength: number | null; }, ): Promise { return request(`/api/v1/projects/${project}/platforms-admin`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); }, async updatePlatform( project: string, platformUuid: string, body: { name?: string; messageFormat?: string; exportLayout?: string; defaultMaxLength?: number | null; isArchived?: boolean; }, ): Promise { return request(`/api/v1/projects/${project}/platforms-admin/${platformUuid}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); }, async environments(project: string): Promise { return request(`/api/v1/projects/${project}/environments`, { headers: { Accept: 'application/json' }, }); }, async createEnvironment( project: string, body: { name: string; slug: string }, ): Promise { return request(`/api/v1/projects/${project}/environments`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); }, async updateEnvironment( project: string, environmentUuid: string, body: { name?: string }, ): Promise { return request( `/api/v1/projects/${project}/environments/${environmentUuid}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }, ); }, // ── Administration globale (super-admin) ───────────────────────────── async adminUsers(search?: string): Promise { const query = search ? `?q=${encodeURIComponent(search)}` : ''; return request(`/api/v1/admin/users${query}`, { headers: { Accept: 'application/json' }, }); }, async updateAdminUser( uuid: string, body: { isActive?: boolean; isSuperAdmin?: boolean }, ): Promise { return request(`/api/v1/admin/users/${uuid}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); }, async adminProjects(): Promise { return request('/api/v1/admin/projects', { headers: { Accept: 'application/json' }, }); }, async createProject(body: { name: string; slug: string; description: string; sourceLocale: string; targetLocales: string[]; }): Promise { return request('/api/v1/admin/projects', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); }, // ── Invitation (endpoints publics) ─────────────────────────────────── async invitationPreview(token: string): Promise { return request(`/api/v1/invitations/${encodeURIComponent(token)}`, { headers: { Accept: 'application/json' }, }); }, async acceptInvitation( token: string, body: { name: string; password: string }, ): Promise<{ email: string; name: string; message: string }> { return request(`/api/v1/invitations/${encodeURIComponent(token)}/accept`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); }, async writeTranslation( keyUuid: string, locale: string, body: { value: string | null; status?: TranslationStatus; version?: number }, ): Promise { return request( `/api/v1/keys/${keyUuid}/translations/${encodeURIComponent(locale)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/merge-patch+json', Accept: 'application/json', }, body: JSON.stringify(body), }, ); }, };