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>
523 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|