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>
328 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|