TQ-Slator/assets/pages/AdminPage.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

523 lines
22 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '@/api/client';
import type { AdminProject, AdminUser } from '@/api/types';
import { useAuth, humanMessage } from '@/auth/AuthProvider';
/**
* Administration hors projet.
*
* Volontairement séparée des écrans Membres et Intégration, qui répondent à
* « qui a accès à CE projet ? ». Ici la question est autre : « qui existe dans
* l'outil, et où va-t-il ? ». Elle traverse les projets, donc elle traverse
* aussi les administrateurs de projet — d'où le rôle super-administrateur.
*
* Deux onglets, pas davantage. Un panneau d'administration qui grossit sans
* discipline devient l'endroit où l'on range ce qu'on ne sait pas classer.
*/
export function AdminPage() {
const { user } = useAuth();
const [params, setParams] = useSearchParams();
const tab = params.get('tab') === 'projects' ? 'projects' : 'users';
if (user && !user.isSuperAdmin) {
return <NotSuperAdmin />;
}
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">
<div className="flex items-center gap-1">
<Link to="/projects" className="mr-2 text-sm font-semibold text-ink-900 hover:text-accent">
TQ-Slator
</Link>
<div className="h-5 w-px bg-ink-200" />
<Link
to="/projects"
className="rounded px-2.5 py-1 text-sm text-ink-600 transition-colors hover:bg-ink-100"
>
Projets
</Link>
<span className="rounded bg-accent-soft px-2.5 py-1 text-sm font-medium text-accent">
Administration
</span>
<span className="ml-auto text-xs text-ink-400">{user?.name}</span>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-xl font-semibold tracking-tight text-ink-900">Administration</h1>
<p className="mt-1 text-sm text-ink-500">
Comptes et projets de toute l'organisation.
</p>
<div className="mt-6 flex gap-1 border-b border-ink-200">
{(
[
['users', 'Utilisateurs'],
['projects', 'Projets'],
] as const
).map(([value, label]) => (
<button
key={value}
onClick={() => setParams({ tab: value }, { replace: true })}
className={`-mb-px border-b-2 px-3 py-2 text-sm transition-colors ${
tab === value
? 'border-accent font-medium text-accent'
: 'border-transparent text-ink-500 hover:text-ink-800'
}`}
>
{label}
</button>
))}
</div>
{tab === 'users' ? <UsersTab /> : <ProjectsTab />}
</div>
</div>
</div>
);
}
function NotSuperAdmin() {
return (
<div className="flex h-full items-center justify-center px-6">
<div className="max-w-md text-center">
<p className="text-lg font-medium text-ink-800">Réservé aux super-administrateurs</p>
<p className="mt-2 text-sm text-ink-500">
Cette page donne accès aux comptes de toute l'organisation. Pour gérer les accès
d'un projet dont vous êtes administrateur, ouvrez ce projet et allez dans
« Membres ».
</p>
<Link
to="/projects"
className="mt-6 inline-block rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
Revenir aux projets
</Link>
</div>
</div>
);
}
// ── Utilisateurs ─────────────────────────────────────────────────────────
function UsersTab() {
const [search, setSearch] = useState('');
const data = useQuery({
queryKey: ['admin-users', search],
queryFn: () => api.adminUsers(search || undefined),
});
const users = data.data?.users ?? [];
const orphans = users.filter((u) => u.memberships.length === 0).length;
return (
<div className="mt-6">
<div className="flex items-center gap-3">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un nom ou une adresse…"
className="min-w-64 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<span className="shrink-0 text-xs text-ink-400">
{users.length} compte{users.length > 1 ? 's' : ''}
</span>
</div>
{/* Un compte sans projet ne voit rien du tout. C'est soit une
invitation oubliée, soit un départ mal soldé dans les deux cas
quelque chose à traiter, pas une curiosité. */}
{orphans > 0 && (
<p className="mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-900">
{orphans} compte{orphans > 1 ? 's' : ''} sans aucun projet : ces personnes se
connectent mais ne voient rien.
</p>
)}
<div className="mt-4 divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{users.map((user) => (
<UserRow
key={user.uuid}
user={user}
superAdminCount={data.data?.superAdminCount ?? 0}
/>
))}
{data.isSuccess && users.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-ink-400">Aucun compte trouvé.</p>
)}
</div>
</div>
);
}
function UserRow({ user, superAdminCount }: { user: AdminUser; superAdminCount: number }) {
const { user: current } = useAuth();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const isSelf = current?.email === user.email;
const isLastSuperAdmin = user.isSuperAdmin && superAdminCount <= 1;
const update = useMutation({
mutationFn: (body: { isActive?: boolean; isSuperAdmin?: boolean }) =>
api.updateAdminUser(user.uuid, body),
onSuccess: () => {
setError(null);
void queryClient.invalidateQueries({ queryKey: ['admin-users'] });
},
onError: (caught) => setError(humanMessage(caught)),
});
return (
<div className={`px-4 py-3 ${user.isActive ? '' : 'bg-ink-50'}`}>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className={`text-sm ${user.isActive ? 'text-ink-800' : 'text-ink-400 line-through'}`}>
{user.name}
</span>
<span className="text-xs text-ink-400">{user.email}</span>
{user.isSuperAdmin && (
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[11px] font-medium text-accent">
super-admin
</span>
)}
{user.isExternal && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[11px] text-amber-900">
externe
</span>
)}
{!user.isActive && (
<span className="rounded bg-ink-200 px-1.5 py-0.5 text-[11px] text-ink-600">
désactivé
</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-2">
<button
onClick={() => update.mutate({ isSuperAdmin: !user.isSuperAdmin })}
disabled={update.isPending || isSelf || isLastSuperAdmin}
title={
isSelf
? 'On ne modifie pas son propre rôle depuis cet écran.'
: isLastSuperAdmin
? 'Il doit rester au moins un super-administrateur.'
: undefined
}
className="rounded border border-ink-300 px-2 py-1 text-xs text-ink-600 transition-colors hover:bg-ink-100 disabled:cursor-not-allowed disabled:opacity-40"
>
{user.isSuperAdmin ? 'Retirer super-admin' : 'Nommer super-admin'}
</button>
<button
onClick={() => update.mutate({ isActive: !user.isActive })}
disabled={update.isPending || isSelf || (!user.isActive ? false : isLastSuperAdmin)}
title={isSelf ? 'On ne désactive pas son propre compte.' : undefined}
className={`rounded px-2 py-1 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
user.isActive
? 'border border-red-200 text-red-700 hover:bg-red-50'
: 'border border-ink-300 text-ink-600 hover:bg-ink-100'
}`}
>
{user.isActive ? 'Désactiver' : 'Réactiver'}
</button>
</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-ink-400">
{user.memberships.length === 0 ? (
<span className="text-amber-700">aucun projet</span>
) : (
user.memberships.map((m) => (
<span key={m.projectUuid}>
<Link
to={`/p/${m.projectUuid}/members`}
className="text-ink-600 hover:text-accent"
>
{m.project}
</Link>
<span className="text-ink-300"> · </span>
{m.role}
<span className="text-ink-300"> · </span>
<span className="font-mono">
{m.coversAllLocales ? 'toutes langues' : m.locales.join(', ')}
</span>
</span>
))
)}
<span className="ml-auto">
{user.lastLoginAt
? `dernière connexion le ${new Date(user.lastLoginAt).toLocaleDateString('fr-FR')}`
: 'jamais connecté'}
</span>
</div>
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
</div>
);
}
// ── Projets ──────────────────────────────────────────────────────────────
function ProjectsTab() {
const data = useQuery({ queryKey: ['admin-projects'], queryFn: () => api.adminProjects() });
const queryClient = useQueryClient();
return (
<div className="mt-6">
<CreateProjectForm
locales={data.data?.locales ?? []}
onDone={() => void queryClient.invalidateQueries({ queryKey: ['admin-projects'] })}
/>
<div className="mt-8 divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{data.data?.projects.map((project) => (
<ProjectRow key={project.uuid} project={project} />
))}
</div>
</div>
);
}
function ProjectRow({ project }: { project: AdminProject }) {
return (
<div className="px-4 py-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Link
to={`/p/${project.uuid}/editor`}
className="text-sm font-medium text-ink-800 hover:text-accent"
>
{project.name}
</Link>
<span className="font-mono text-[11px] text-ink-400">{project.slug}</span>
{/* Un projet sans propriétaire n'est administrable par personne :
plus d'invitation possible, plus de publication. C'est la
seule anomalie que cet écran doit crier. */}
{project.owners.length === 0 && (
<span className="rounded bg-red-100 px-1.5 py-0.5 text-[11px] font-medium text-red-800">
aucun propriétaire
</span>
)}
{/* Sans plateforme, aucune clé ne peut être rattachée et aucun
bundle ne peut être construit : le projet existe mais ne
livre rien. Un projet fraîchement créé est dans cet état. */}
{project.platformCount === 0 && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-900">
aucune plateforme
</span>
)}
<span className="ml-auto flex shrink-0 gap-2 text-xs">
<Link
to={`/p/${project.uuid}/members`}
className="rounded border border-ink-300 px-2 py-1 text-ink-600 hover:bg-ink-100"
>
Membres
</Link>
<Link
to={`/p/${project.uuid}/integration`}
className="rounded border border-ink-300 px-2 py-1 text-ink-600 hover:bg-ink-100"
>
Intégration
</Link>
</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 text-[11px] text-ink-400">
<span className="font-mono">
{project.sourceLocale} {project.targetLocales.join(', ') || 'aucune cible'}
</span>
<span>{project.platformCount} plateformes</span>
<span>{project.environmentCount} environnements</span>
<span>{project.memberCount} membres</span>
{project.owners.length > 0 && <span>propriétaire : {project.owners.join(', ')}</span>}
</div>
</div>
);
}
function CreateProjectForm({
locales,
onDone,
}: {
locales: { code: string; name: string; nativeName: string }[];
onDone: () => void;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [description, setDescription] = useState('');
const [sourceLocale, setSourceLocale] = useState('fr-FR');
const [targets, setTargets] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const create = useMutation({
mutationFn: () =>
api.createProject({ name, slug, description, sourceLocale, targetLocales: targets }),
onSuccess: (project) => {
setMessage(
`Projet « ${project.name} » créé, avec ses trois environnements. Vous en êtes propriétaire.`,
);
setError(null);
setName('');
setSlug('');
setDescription('');
setTargets([]);
// On referme : un formulaire vidé sur place ressemble à un échec
// silencieux. Refermer fait apparaître la confirmation, et la
// nouvelle ligne juste en dessous.
setOpen(false);
onDone();
},
onError: (caught) => {
setError(humanMessage(caught));
setMessage(null);
},
});
if (!open) {
return (
<div>
<button
onClick={() => setOpen(true)}
className="rounded-md bg-accent px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Nouveau projet
</button>
{message && <p className="mt-3 text-xs text-emerald-700">{message}</p>}
</div>
);
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
className="rounded-lg border border-ink-200 bg-white p-5 shadow-sm"
>
<h2 className="mb-4 text-sm font-medium text-ink-800">Nouveau projet</h2>
<div className="flex flex-wrap gap-3">
<input
required
value={name}
onChange={(e) => {
setName(e.target.value);
// Le slug se déduit du nom tant qu'on n'y a pas touché.
// Il finit dans des URL de livraison et dans les dépôts
// clients : le laisser vide serait un piège.
setSlug(
e.target.value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
);
}}
placeholder="Nom du projet"
className="min-w-56 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<input
required
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="slug-du-projet"
className="min-w-44 rounded-md border border-ink-300 px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
<select
value={sourceLocale}
onChange={(e) => setSourceLocale(e.target.value)}
className="rounded-md border border-ink-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent"
>
{locales.map((l) => (
<option key={l.code} value={l.code}>
Source : {l.nativeName}
</option>
))}
</select>
</div>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (facultative)"
className="mt-3 w-full rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<p className="mb-2 mt-4 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Langues cibles
</p>
<div className="flex flex-wrap gap-1.5">
{locales
.filter((l) => l.code !== sourceLocale)
.map((l) => {
const on = targets.includes(l.code);
return (
<button
key={l.code}
type="button"
onClick={() =>
setTargets((prev) =>
on ? prev.filter((c) => c !== l.code) : [...prev, l.code],
)
}
className={`rounded px-2 py-1 text-xs transition-colors ${
on ? 'bg-accent text-white' : 'bg-ink-100 text-ink-600 hover:bg-ink-200'
}`}
>
{l.nativeName}
</button>
);
})}
</div>
<p className="mt-2 text-[11px] text-ink-400">
La langue source ne se change plus après la création : la modifier invaliderait
l'empreinte de chaque traduction et basculerait tout le contenu en « à revoir ».
</p>
{/* Hauteur bornée : une erreur serveur inattendue peut faire
plusieurs milliers de caractères, et pousserait sinon le bouton
de validation hors de l'écran. */}
{error && (
<p className="mt-3 max-h-24 overflow-y-auto rounded bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
</p>
)}
<div className="mt-4 flex items-center gap-3">
<button
type="submit"
disabled={create.isPending}
className="rounded-md bg-accent px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:bg-ink-300"
>
{create.isPending ? 'Création…' : 'Créer le projet'}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="text-sm text-ink-500 hover:text-ink-800"
>
Annuler
</button>
</div>
</form>
);
}