Système de gestion de traductions pensé en CMS headless : un back-office pour ceux qui traduisent, une API pour ce qui consomme. Architecture - Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en même origine — ce qui rend viable le cookie de session plutôt qu'un jeton en localStorage. - Deux APIs séparées : Management (session ou clé) et Delivery (stateless, clé seule). Les fusionner ferait porter à chaque lecture de bundle le coût de la session. - Stockage canonique en ICU MessageFormat, sérialisation par plateforme. Le format d'une plateforme ne contamine pas la base. - Publication par releases immuables ; le déploiement est un déplacement de pointeur, donc le rollback aussi. - Isolation multi-organisation par filtre Doctrine, avec un test d'architecture qui casse la CI si une entité échappe à l'invariant. Éditeur, deux vues - Par langue : source et cible, jamais douze colonnes. Grille virtualisée, saisie sans bouton « Enregistrer », panneau de contexte permanent. - Par clé : une clé, toutes ses langues empilées et repliées. Répond à « ce libellé est-il prêt partout ? ». - Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner. - Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ par catégorie CLDR de la langue cible. Administration - Deux niveaux : projet (membres, clés API, plateformes) et organisation (annuaire des comptes, création de projets). - Invitations par e-mail, jeton 256 bits stocké haché. - Désactiver un compte coupe les sessions en cours, pas seulement les connexions suivantes. - Les plateformes s'archivent ; ni elles ni les environnements ne se suppriment — la trace explique pourquoi telle clé existe. CLI tqs - PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend ni de Composer ni de la disponibilité de TQ-Slator. - init / push / pull / status ; le sync est non destructif par défaut et son prune est scopé plateforme. 126 tests, PHPStan niveau 8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
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<T>(payload: { member?: T[]; totalItems?: number }): Page<T> {
|
|
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<CurrentUser> {
|
|
return request<CurrentUser>('/api/v1/auth/me', { headers: { Accept: 'application/json' } });
|
|
},
|
|
|
|
async login(email: string, password: string): Promise<CurrentUser> {
|
|
return request<CurrentUser>('/api/v1/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
},
|
|
|
|
async logout(): Promise<void> {
|
|
await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
|
},
|
|
|
|
async projects(): Promise<Project[]> {
|
|
const payload = await request<{ member?: Project[] }>('/api/v1/projects');
|
|
return payload.member ?? [];
|
|
},
|
|
|
|
async stats(projectUuid: string): Promise<ProjectStats> {
|
|
return request<ProjectStats>(`/api/v1/projects/${projectUuid}/stats`, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
},
|
|
|
|
async namespaces(projectUuid: string, locale: string): Promise<NamespaceTree> {
|
|
return request<NamespaceTree>(
|
|
`/api/v1/projects/${projectUuid}/namespaces?locale=${encodeURIComponent(locale)}`,
|
|
{ headers: { Accept: 'application/json' } },
|
|
);
|
|
},
|
|
|
|
async grid(query: GridQuery): Promise<Page<GridRow>> {
|
|
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<MemberList> {
|
|
return request<MemberList>(`/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<void> {
|
|
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<void> {
|
|
await request(`/api/v1/projects/${project}/members/${memberUuid}`, {
|
|
method: 'DELETE',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
},
|
|
|
|
async apiKeys(project: string): Promise<ApiKeyList> {
|
|
return request<ApiKeyList>(`/api/v1/projects/${project}/api-keys`, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
},
|
|
|
|
async createApiKey(
|
|
project: string,
|
|
body: { name: string; environment: string; scopes: string[] },
|
|
): Promise<CreatedApiKey> {
|
|
return request<CreatedApiKey>(`/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<void> {
|
|
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<GridQuery, 'locale'> & { focus?: boolean }): Promise<Page<KeyRow>> {
|
|
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<PlatformList> {
|
|
return request<PlatformList>(`/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<AdminPlatform> {
|
|
return request<AdminPlatform>(`/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<AdminPlatform> {
|
|
return request<AdminPlatform>(`/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<EnvironmentList> {
|
|
return request<EnvironmentList>(`/api/v1/projects/${project}/environments`, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
},
|
|
|
|
async createEnvironment(
|
|
project: string,
|
|
body: { name: string; slug: string },
|
|
): Promise<AdminEnvironment> {
|
|
return request<AdminEnvironment>(`/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<AdminEnvironment> {
|
|
return request<AdminEnvironment>(
|
|
`/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<AdminUserList> {
|
|
const query = search ? `?q=${encodeURIComponent(search)}` : '';
|
|
|
|
return request<AdminUserList>(`/api/v1/admin/users${query}`, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
},
|
|
|
|
async updateAdminUser(
|
|
uuid: string,
|
|
body: { isActive?: boolean; isSuperAdmin?: boolean },
|
|
): Promise<AdminUser> {
|
|
return request<AdminUser>(`/api/v1/admin/users/${uuid}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
},
|
|
|
|
async adminProjects(): Promise<AdminProjectList> {
|
|
return request<AdminProjectList>('/api/v1/admin/projects', {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
},
|
|
|
|
async createProject(body: {
|
|
name: string;
|
|
slug: string;
|
|
description: string;
|
|
sourceLocale: string;
|
|
targetLocales: string[];
|
|
}): Promise<AdminProject> {
|
|
return request<AdminProject>('/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<InvitationPreview> {
|
|
return request<InvitationPreview>(`/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<TranslationEntry> {
|
|
return request<TranslationEntry>(
|
|
`/api/v1/keys/${keyUuid}/translations/${encodeURIComponent(locale)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/merge-patch+json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
},
|
|
};
|