TQ-Slator/assets/pages/IntegrationPage.tsx
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
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>
2026-08-21 08:16:05 +02:00

328 lines
15 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { api } from '@/api/client';
import type { CreatedApiKey } from '@/api/types';
import { humanMessage } from '@/auth/AuthProvider';
import { ProjectNav } from '@/components/ProjectNav';
import { RequiresAdmin } from '@/components/RequiresAdmin';
/**
* Clés API et prise en main.
*
* Deux idées gouvernent cet écran :
*
* 1. **Le secret n'apparaît qu'une fois**, immédiatement après la création, dans
* un encart qu'on ne peut pas manquer. Il n'est pas stocké en clair : le
* réafficher est techniquement impossible, et le dire franchement évite de
* laisser croire qu'on pourra le retrouver plus tard.
*
* 2. **Le mode d'emploi est SOUS la clé**, pas dans une documentation à part.
* Le moment où quelqu'un crée une clé est exactement celui où il cherche
* quoi en faire.
*/
export function IntegrationPage() {
const { projectUuid = '' } = useParams();
const queryClient = useQueryClient();
const [created, setCreated] = useState<CreatedApiKey | null>(null);
const stats = useQuery({ queryKey: ['stats', projectUuid], queryFn: () => api.stats(projectUuid) });
const data = useQuery({ queryKey: ['api-keys', projectUuid], queryFn: () => api.apiKeys(projectUuid) });
const revoke = useMutation({
mutationFn: (uuid: string) => api.revokeApiKey(projectUuid, uuid),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['api-keys', projectUuid] }),
});
// Le serveur refuse déjà ; ici on refuse LISIBLEMENT.
if (stats.isSuccess && !stats.data.viewer.canAdminister) {
return <RequiresAdmin projectUuid={projectUuid} />;
}
return (
<div className="flex h-full flex-col bg-ink-100">
<header className="shrink-0 border-b border-ink-200 bg-white px-4 py-2.5">
<ProjectNav projectUuid={projectUuid} canAdminister={stats.data?.viewer.canAdminister ?? false} />
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="text-xl font-semibold tracking-tight text-ink-900">Intégration</h1>
<p className="mt-1 text-sm text-ink-500">
Clés d'accès et prise en main du CLI.
</p>
{created !== null && <SecretBanner created={created} onDismiss={() => setCreated(null)} />}
<CreateKeyForm
projectUuid={projectUuid}
environments={(data.data?.environments ?? []).map((e) => e.slug)}
scopes={(data.data?.scopes ?? []).map((s) => s.value)}
onCreated={(key) => {
setCreated(key);
void queryClient.invalidateQueries({ queryKey: ['api-keys', projectUuid] });
}}
/>
<section className="mt-8">
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Clés existantes
</h2>
<div className="divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{data.data?.keys.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-ink-400">
Aucune clé. Créez-en une pour brancher une application.
</p>
)}
{data.data?.keys.map((key) => (
<div
key={key.uuid}
className={`flex flex-wrap items-center gap-3 px-4 py-3 ${key.isUsable ? '' : 'opacity-50'}`}
>
<div className="min-w-40 flex-1">
<p className="text-sm font-medium text-ink-800">{key.name}</p>
<p className="font-mono text-[11px] text-ink-400">{key.prefix}…</p>
</div>
<span className="rounded bg-ink-100 px-1.5 py-0.5 text-[11px] text-ink-600">
{key.environment}
</span>
<span className="font-mono text-[10px] text-ink-400">
{key.scopes.join(' · ')}
</span>
{/* Une clé jamais utilisée est soit oubliée, soit
remplacée sans avoir été révoquée. Dans les deux
cas elle devrait disparaître. */}
<span className="min-w-28 text-[11px] text-ink-400">
{key.revokedAt !== null
? 'révoquée'
: key.lastUsedAt === null
? 'jamais utilisée'
: `vue le ${new Date(key.lastUsedAt).toLocaleDateString('fr-FR')}`}
</span>
{key.isUsable && (
<button
onClick={() => revoke.mutate(key.uuid)}
className="rounded px-2 py-1 text-xs text-ink-400 hover:bg-red-50 hover:text-red-700"
>
Révoquer
</button>
)}
</div>
))}
</div>
</section>
<GettingStarted project={stats.data?.project ?? ''} />
</div>
</div>
</div>
);
}
function SecretBanner({ created, onDismiss }: { created: CreatedApiKey; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
return (
<div className="mt-6 rounded-lg border-2 border-amber-300 bg-amber-50 p-5">
<p className="text-sm font-medium text-amber-900">{created.warning}</p>
<div className="mt-3 flex items-center gap-2">
<code className="flex-1 select-all break-all rounded border border-amber-200 bg-white px-3 py-2 font-mono text-xs text-ink-900">
{created.secret}
</code>
<button
onClick={() => {
void navigator.clipboard?.writeText(created.secret);
setCopied(true);
}}
className="shrink-0 rounded-md bg-amber-600 px-3 py-2 text-xs font-medium text-white hover:bg-amber-700"
>
{copied ? 'Copié' : 'Copier'}
</button>
</div>
<p className="mt-3 text-xs text-amber-800">
Elle n'est pas stockée en clair : la réafficher est impossible. Si vous la perdez,
révoquez-la et créez-en une nouvelle.
</p>
<button onClick={onDismiss} className="mt-3 text-xs text-amber-700 underline-offset-4 hover:underline">
J'ai copié la clé
</button>
</div>
);
}
function CreateKeyForm({
projectUuid,
environments,
scopes,
onCreated,
}: {
projectUuid: string;
environments: string[];
scopes: string[];
onCreated: (key: CreatedApiKey) => void;
}) {
const [name, setName] = useState('');
const [environment, setEnvironment] = useState('');
const [selected, setSelected] = useState<string[]>(['translations:read']);
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: () =>
api.createApiKey(projectUuid, {
name,
environment: environment || (environments[0] ?? ''),
scopes: selected,
}),
onSuccess: (key) => {
setError(null);
setName('');
onCreated(key);
},
onError: (caught) => setError(humanMessage(caught)),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
className="mt-6 rounded-lg border border-ink-200 bg-white p-5 shadow-sm"
>
<h2 className="mb-4 text-sm font-medium text-ink-800">Créer une clé</h2>
<div className="flex flex-wrap gap-3">
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI application web"
className="min-w-56 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<select
value={environment || environments[0] || ''}
onChange={(e) => setEnvironment(e.target.value)}
className="rounded-md border border-ink-300 bg-white px-2 py-2 text-sm outline-none focus:border-accent"
>
{environments.map((slug) => (
<option key={slug} value={slug}>
{slug}
</option>
))}
</select>
<button
type="submit"
disabled={create.isPending || selected.length === 0}
className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
>
{create.isPending ? 'Création' : 'Créer'}
</button>
</div>
<p className="mt-2 text-xs text-ink-400">
Le nom est ce qui permettra de savoir quoi révoquer dans six mois.
</p>
<div className="mt-4">
<p className="mb-1.5 text-xs font-medium text-ink-600">Permissions</p>
<div className="flex flex-wrap gap-1.5">
{scopes.map((scope) => {
const on = selected.includes(scope);
const isWrite = scope.includes('write') || scope.includes('publish');
return (
<button
key={scope}
type="button"
onClick={() =>
setSelected((prev) =>
on ? prev.filter((s) => s !== scope) : [...prev, scope],
)
}
className={`rounded px-2 py-1 font-mono text-[11px] transition-colors ${
on
? isWrite
? 'bg-amber-600 text-white'
: 'bg-accent text-white'
: 'bg-ink-100 text-ink-600 hover:bg-ink-200'
}`}
>
{scope}
</button>
);
})}
</div>
{/* Les scopes d'écriture sont d'une autre couleur : une clé de
production ne devrait en porter aucun, et l'écart visuel rend
l'erreur difficile à commettre sans la voir. */}
<p className="mt-2 text-xs text-ink-400">
Une clé de production n'a besoin que de lecture. Les permissions d'écriture
(en orange) sont réservées aux clés de développement et d'intégration continue.
</p>
</div>
{error !== null && (
<p role="alert" className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
)}
</form>
);
}
function GettingStarted({ project }: { project: string }) {
return (
<section className="mt-8 rounded-lg border border-ink-200 bg-white p-5">
<h2 className="mb-3 text-sm font-medium text-ink-800">Brancher une application</h2>
<ol className="space-y-4 text-sm text-ink-700">
<li>
<p className="font-medium">1. Installer le CLI</p>
<Snippet>{`curl -sSL https://tq-slator.internal/tqs.phar -o /usr/local/bin/tqs\nchmod +x /usr/local/bin/tqs`}</Snippet>
</li>
<li>
<p className="font-medium">2. Lier le dépôt</p>
<Snippet>{`export TQS_API_KEY=tqs_…\ntqs init`}</Snippet>
<p className="mt-1 text-xs text-ink-400">
Projet, environnement, plateformes et langues sont déduits de la clé.
Le fichier <code className="font-mono">tqs.config.json</code> produit se
committe il ne contient aucun secret.
</p>
</li>
<li>
<p className="font-medium">3. Envoyer les clés du code</p>
<Snippet>{`tqs push --dry-run # voir le diff\ntqs push # appliquer`}</Snippet>
</li>
<li>
<p className="font-medium">4. Récupérer les traductions en intégration continue</p>
<Snippet>{`tqs pull\ntqs pull --check # échoue si des fichiers ne sont pas commités\ntqs status --fail-under=80 # échoue si une langue passe sous 80 %`}</Snippet>
</li>
</ol>
<p className="mt-4 text-xs text-ink-400">
Les fichiers récupérés sont commités dans votre dépôt : votre production ne dépend
donc jamais de la disponibilité de TQ-Slator. Projet visé :{' '}
<code className="font-mono">{project}</code>
</p>
</section>
);
}
function Snippet({ children }: { children: string }) {
return (
<pre className="mt-1.5 overflow-x-auto rounded bg-ink-900 px-3 py-2 font-mono text-xs leading-relaxed text-ink-100">
{children}
</pre>
);
}