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 ; } return (
TQ-Slator
Projets Administration {user?.name}

Administration

Comptes et projets de toute l'organisation.

{( [ ['users', 'Utilisateurs'], ['projects', 'Projets'], ] as const ).map(([value, label]) => ( ))}
{tab === 'users' ? : }
); } function NotSuperAdmin() { return (

Réservé aux super-administrateurs

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 ».

Revenir aux projets
); } // ── 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 (
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" /> {users.length} compte{users.length > 1 ? 's' : ''}
{/* 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 && (

{orphans} compte{orphans > 1 ? 's' : ''} sans aucun projet : ces personnes se connectent mais ne voient rien.

)}
{users.map((user) => ( ))} {data.isSuccess && users.length === 0 && (

Aucun compte trouvé.

)}
); } function UserRow({ user, superAdminCount }: { user: AdminUser; superAdminCount: number }) { const { user: current } = useAuth(); const queryClient = useQueryClient(); const [error, setError] = useState(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 (
{user.name} {user.email} {user.isSuperAdmin && ( super-admin )} {user.isExternal && ( externe )} {!user.isActive && ( désactivé )}
{user.memberships.length === 0 ? ( aucun projet ) : ( user.memberships.map((m) => ( {m.project} · {m.role} · {m.coversAllLocales ? 'toutes langues' : m.locales.join(', ')} )) )} {user.lastLoginAt ? `dernière connexion le ${new Date(user.lastLoginAt).toLocaleDateString('fr-FR')}` : 'jamais connecté'}
{error &&

{error}

}
); } // ── Projets ────────────────────────────────────────────────────────────── function ProjectsTab() { const data = useQuery({ queryKey: ['admin-projects'], queryFn: () => api.adminProjects() }); const queryClient = useQueryClient(); return (
void queryClient.invalidateQueries({ queryKey: ['admin-projects'] })} />
{data.data?.projects.map((project) => ( ))}
); } function ProjectRow({ project }: { project: AdminProject }) { return (
{project.name} {project.slug} {/* 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 && ( aucun propriétaire )} {/* 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 && ( aucune plateforme )} Membres Intégration
{project.sourceLocale} → {project.targetLocales.join(', ') || 'aucune cible'} {project.platformCount} plateformes {project.environmentCount} environnements {project.memberCount} membres {project.owners.length > 0 && propriétaire : {project.owners.join(', ')}}
); } 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([]); const [error, setError] = useState(null); const [message, setMessage] = useState(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 (
{message &&

{message}

}
); } return (
{ e.preventDefault(); create.mutate(); }} className="rounded-lg border border-ink-200 bg-white p-5 shadow-sm" >

Nouveau projet

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

Langues cibles

{locales .filter((l) => l.code !== sourceLocale) .map((l) => { const on = targets.includes(l.code); return ( ); })}

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 ».

{/* 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 && (

{error}

)}
); }