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>
367 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|