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 = { 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 ; } return (

Membres

Qui a accès à {stats.data?.projectName}, et sur quelles langues.

l.code)} onDone={() => void queryClient.invalidateQueries({ queryKey: ['members', projectUuid] })} />

Membres actifs

{data.data?.members.map((member) => ( l.code)} /> ))}
{(data.data?.pending.length ?? 0) > 0 && (

Invitations en attente

{/* 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. */}
{data.data?.pending.map((invitation) => (
{invitation.email} {invitation.role} {invitation.locales.length === 0 ? 'toutes langues' : invitation.locales.join(', ')} expire le{' '} {new Date(invitation.expiresAt).toLocaleDateString('fr-FR')}
))}
)}
); } function InviteForm({ projectUuid, locales, onDone, }: { projectUuid: string; locales: string[]; onDone: () => void; }) { const [email, setEmail] = useState(''); const [role, setRole] = useState('translator'); const [selected, setSelected] = useState([]); const [isExternal, setIsExternal] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(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 (
{ e.preventDefault(); invite.mutate(); }} className="mt-6 rounded-lg border border-ink-200 bg-white p-5 shadow-sm" >

Inviter quelqu'un

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" />

{ROLE_HELP[role]}

{showLocales && (

Langues autorisées{' '} — aucune sélection = toutes les langues du projet

{locales.map((code) => { const on = selected.includes(code); return ( ); })}
)} {message !== null && (

{message}

)} {error !== null && (

{error}

)}
); } function MemberRow({ projectUuid, member, locales, }: { projectUuid: string; member: Member; locales: string[]; }) { const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const [error, setError] = useState(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 (

{member.name} {member.isExternal && ( externe )}

{member.email}

{member.lastLoginAt === null ? 'jamais connecté' : new Date(member.lastLoginAt).toLocaleDateString('fr-FR')}
{editing && (
{locales.map((code) => { const on = member.locales.includes(code); return ( ); })} aucune sélection = toutes les langues
)} {error !== null && (

{error}

)}
); }