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

367 lines
16 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 { Member, ProjectRole } from '@/api/types';
import { humanMessage } from '@/auth/AuthProvider';
import { ProjectNav } from '@/components/ProjectNav';
import { RequiresAdmin } from '@/components/RequiresAdmin';
const ROLE_HELP: Record<ProjectRole, string> = {
owner: 'Tout, y compris la suppression du projet.',
admin: 'Membres, langues, plateformes, clés API, publication.',
developer: 'Clés, plateformes, releases. N\'écrit PAS de traductions.',
translator: 'Écrit les traductions de ses langues assignées.',
reviewer: 'Comme traducteur, plus la validation.',
viewer: 'Lecture seule.',
};
/**
* Administration des accès.
*
* L'écran est construit autour d'une question : « qui peut écrire quoi ? ».
* D'où les langues affichées sur chaque ligne plutôt que cachées derrière une
* fiche — c'est l'information qui décide si un prestataire est correctement
* cloisonné, et elle doit se vérifier d'un coup d'œil.
*/
export function MembersPage() {
const { projectUuid = '' } = useParams();
const queryClient = useQueryClient();
const stats = useQuery({ queryKey: ['stats', projectUuid], queryFn: () => api.stats(projectUuid) });
const data = useQuery({ queryKey: ['members', projectUuid], queryFn: () => api.members(projectUuid) });
const targetLocales = (stats.data?.locales ?? []).filter((l) => !l.isSource);
// 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">Membres</h1>
<p className="mt-1 text-sm text-ink-500">
Qui a accès à {stats.data?.projectName}, et sur quelles langues.
</p>
<InviteForm
projectUuid={projectUuid}
locales={targetLocales.map((l) => l.code)}
onDone={() => void queryClient.invalidateQueries({ queryKey: ['members', projectUuid] })}
/>
<section className="mt-8">
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Membres actifs
</h2>
<div className="divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{data.data?.members.map((member) => (
<MemberRow
key={member.uuid}
projectUuid={projectUuid}
member={member}
locales={targetLocales.map((l) => l.code)}
/>
))}
</div>
</section>
{(data.data?.pending.length ?? 0) > 0 && (
<section className="mt-8">
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Invitations en attente
</h2>
{/* Les invitations non acceptées comptent comme des accès
en cours d'octroi : les séparer dans un autre écran
laisserait un angle mort à qui audite les accès. */}
<div className="divide-y divide-ink-100 overflow-hidden rounded-lg border border-dashed border-ink-300 bg-white">
{data.data?.pending.map((invitation) => (
<div key={invitation.email} className="flex items-center gap-3 px-4 py-3">
<span className="flex-1 truncate text-sm text-ink-700">
{invitation.email}
</span>
<span className="rounded bg-ink-100 px-1.5 py-0.5 text-[11px] text-ink-600">
{invitation.role}
</span>
<span className="font-mono text-[11px] text-ink-400">
{invitation.locales.length === 0
? 'toutes langues'
: invitation.locales.join(', ')}
</span>
<span className="text-[11px] text-ink-400">
expire le{' '}
{new Date(invitation.expiresAt).toLocaleDateString('fr-FR')}
</span>
</div>
))}
</div>
</section>
)}
</div>
</div>
</div>
);
}
function InviteForm({
projectUuid,
locales,
onDone,
}: {
projectUuid: string;
locales: string[];
onDone: () => void;
}) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<ProjectRole>('translator');
const [selected, setSelected] = useState<string[]>([]);
const [isExternal, setIsExternal] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const invite = useMutation({
mutationFn: () => api.invite(projectUuid, { email, role, locales: selected, isExternal }),
onSuccess: (result) => {
setMessage(result.message);
setError(null);
setEmail('');
setSelected([]);
onDone();
},
onError: (caught) => {
setError(humanMessage(caught));
setMessage(null);
},
});
// Les langues ne concernent que les rôles qui écrivent : les proposer à un
// developer suggérerait une restriction qui n'existe pas pour lui.
const showLocales = role === 'translator' || role === 'reviewer';
return (
<form
onSubmit={(e) => {
e.preventDefault();
invite.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">Inviter quelqu'un</h2>
<div className="flex flex-wrap gap-3">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="adresse@exemple.fr"
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={role}
onChange={(e) => setRole(e.target.value as ProjectRole)}
className="rounded-md border border-ink-300 bg-white px-2 py-2 text-sm outline-none focus:border-accent"
>
{(Object.keys(ROLE_HELP) as ProjectRole[]).map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
type="submit"
disabled={invite.isPending}
className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
>
{invite.isPending ? 'Envoi' : 'Inviter'}
</button>
</div>
<p className="mt-2 text-xs text-ink-400">{ROLE_HELP[role]}</p>
{showLocales && (
<div className="mt-4">
<p className="mb-1.5 text-xs font-medium text-ink-600">
Langues autorisées{' '}
<span className="font-normal text-ink-400">
— aucune sélection = toutes les langues du projet
</span>
</p>
<div className="flex flex-wrap gap-1.5">
{locales.map((code) => {
const on = selected.includes(code);
return (
<button
key={code}
type="button"
onClick={() =>
setSelected((prev) =>
on ? prev.filter((c) => c !== code) : [...prev, code],
)
}
className={`rounded px-2 py-1 font-mono text-xs transition-colors ${
on ? 'bg-accent text-white' : 'bg-ink-100 text-ink-600 hover:bg-ink-200'
}`}
>
{code}
</button>
);
})}
</div>
</div>
)}
<label className="mt-4 flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isExternal}
onChange={(e) => setIsExternal(e.target.checked)}
/>
Prestataire externe
<span className="text-ink-400">
— fenêtre d'invitation raccourcie et connexions journalisées
</span>
</label>
{message !== null && (
<p className="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{message}</p>
)}
{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 MemberRow({
projectUuid,
member,
locales,
}: {
projectUuid: string;
member: Member;
locales: string[];
}) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = () => void queryClient.invalidateQueries({ queryKey: ['members', projectUuid] });
const update = useMutation({
mutationFn: (body: { role?: ProjectRole; locales?: string[] }) =>
api.updateMember(projectUuid, member.uuid, body),
onSuccess: () => {
setError(null);
setEditing(false);
refresh();
},
onError: (caught) => setError(humanMessage(caught)),
});
const remove = useMutation({
mutationFn: () => api.removeMember(projectUuid, member.uuid),
onSuccess: refresh,
onError: (caught) => setError(humanMessage(caught)),
});
return (
<div className="px-4 py-3">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-40 flex-1">
<p className="text-sm font-medium text-ink-800">
{member.name}
{member.isExternal && (
<span className="ml-2 rounded bg-ink-200 px-1.5 py-0.5 text-[10px] font-normal text-ink-600">
externe
</span>
)}
</p>
<p className="text-xs text-ink-400">{member.email}</p>
</div>
<select
value={member.role}
onChange={(e) => update.mutate({ role: e.target.value as ProjectRole })}
className="rounded border border-ink-300 bg-white px-2 py-1 text-xs outline-none focus:border-accent"
>
{(Object.keys(ROLE_HELP) as ProjectRole[]).map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
onClick={() => setEditing((v) => !v)}
className="min-w-32 rounded px-2 py-1 text-left font-mono text-[11px] text-ink-500 hover:bg-ink-100"
title="Modifier les langues autorisées"
>
{member.coversAllLocales ? 'toutes langues' : member.locales.join(', ')}
</button>
<span className="text-[11px] text-ink-400">
{member.lastLoginAt === null
? 'jamais connecté'
: new Date(member.lastLoginAt).toLocaleDateString('fr-FR')}
</span>
<button
onClick={() => remove.mutate()}
className="rounded px-2 py-1 text-xs text-ink-400 hover:bg-red-50 hover:text-red-700"
>
Retirer
</button>
</div>
{editing && (
<div className="mt-3 flex flex-wrap gap-1.5 border-t border-ink-100 pt-3">
{locales.map((code) => {
const on = member.locales.includes(code);
return (
<button
key={code}
onClick={() =>
update.mutate({
locales: on
? member.locales.filter((c) => c !== code)
: [...member.locales, code],
})
}
className={`rounded px-2 py-1 font-mono text-xs transition-colors ${
on ? 'bg-accent text-white' : 'bg-ink-100 text-ink-600 hover:bg-ink-200'
}`}
>
{code}
</button>
);
})}
<span className="self-center text-xs text-ink-400">
aucune sélection = toutes les langues
</span>
</div>
)}
{error !== null && (
<p role="alert" className="mt-2 text-xs text-red-700">
{error}
</p>
)}
</div>
);
}