From 75fa224bd438688f1d66edf9838c05f78b1b35a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 07:21:31 +0000 Subject: [PATCH] =?UTF-8?q?Les=20coordonn=C3=A9es=20des=20b=C3=A9n=C3=A9vo?= =?UTF-8?q?les=20se=20voient=20enfin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les numéros de téléphone étaient bien enregistrés — la route publique /api/signup les écrit, getEventWithDetails les charge, la page les passe au composant, et le CSV les exporte. Une inscription réelle de production porte bien son numéro. Mais à l'écran, ils étaient sous trois couches : - un autre onglet (?onglet=benevoles), alors que l'aperçu n'affiche qu'un compteur « N inscriptions » qui prouve que les gens sont là sans dire qui ; - un
replié, « Voir les N personnes inscrites » ; - une ligne en text-slate-400 sur bg-slate-50, mesurée à 2,45:1 pour un seuil lisible de 4,5:1 — la moitié de ce qu'il faut. Qui cherchait un numéro concluait qu'il n'avait pas été enregistré. Les réunions, elles, avaient reçu le bon traitement : téléphone affiché d'emblée, cliquable en tel:. Les événements sont désormais alignés dessus, et le pli est supprimé : - les inscrits s'affichent directement sous leur créneau ; - téléphone et e-mail sur leur propre ligne, cliquables, à 7,24:1 ; - « Aucune coordonnée laissée » quand la personne n'en a pas donné, au lieu du silence ; - cibles tactiles portées à 44 px, des deux côtés — c'est l'écran qu'on consulte debout, la veille, pour joindre quelqu'un. Au passage, sur les mêmes écrans : la barre d'actions d'un créneau était en shrink-0 et débordait de 115 px à 320 px de large ; ses trois boutons, l'état vide des créneaux, « Personne pour l'instant » et « Fiche événement » étaient tous sous le seuil de contraste ; et les rouges Tailwind par défaut passent au coral de la maison. Deux fuites refermées, trouvées en auditant ces écrans : - list_event_signups renvoyait `signups: true`, donc le cancelToken. Ce n'est pas une donnée mais un pouvoir : qui le détient peut désinscrire la personne sans être authentifié. L'outil énumère maintenant ses colonnes, comme le fait déjà getEventWithDetails. - getEventByShareToken, qui alimente une page PUBLIQUE, chargeait la ligne d'inscription entière alors que seul son nombre est lu. Elle ne charge plus que l'identifiant : la règle de confidentialité n'était écrite qu'en commentaire, elle est maintenant dans la requête. Vérifié sur une instance réelle, sur quatre inscrits couvrant les quatre cas (les deux coordonnées, téléphone seul, e-mail seul, aucune) : tout est visible sans aucun clic, plus aucun texte sous 4,5:1 sur les deux onglets, plus aucune cible sous 44 px, et aucun débordement de 320 à 1920 px. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD --- src/app/dashboard/events/[id]/page.tsx | 6 +- src/components/slot-manager.tsx | 77 ++++++++++++++++++-------- src/lib/data.ts | 8 ++- src/lib/mcp/register-core-tools.ts | 17 +++++- 4 files changed, 79 insertions(+), 29 deletions(-) diff --git a/src/app/dashboard/events/[id]/page.tsx b/src/app/dashboard/events/[id]/page.tsx index 925c99f..b5fbb3c 100644 --- a/src/app/dashboard/events/[id]/page.tsx +++ b/src/app/dashboard/events/[id]/page.tsx @@ -208,7 +208,7 @@ export default async function EventDetailPage({ > {EVENT_STATUS_LABELS[event.status]} - + Fiche événement @@ -284,7 +284,7 @@ export default async function EventDetailPage({

{gens.length === 0 ? ( -

Personne pour l’instant.

+

Personne pour l’instant.

) : (
    {gens.map((r) => { @@ -307,7 +307,7 @@ export default async function EventDetailPage({ {telephone && ( {telephone} diff --git a/src/components/slot-manager.tsx b/src/components/slot-manager.tsx index 24dac38..4a73484 100644 --- a/src/components/slot-manager.tsx +++ b/src/components/slot-manager.tsx @@ -160,7 +160,7 @@ export function SlotManager({ className="space-y-3 rounded-xl border border-slate-200 bg-slate-50 p-4" > {error && ( -

    +

    {error}

    )} @@ -215,7 +215,7 @@ export function SlotManager({ )} {slots.length === 0 ? ( -

    +

    Aucun créneau de bénévolat. Ajoutez-en pour recueillir les inscriptions via le lien public.

    @@ -232,15 +232,18 @@ export function SlotManager({ key={slot.id} className="rounded-xl border border-slate-200 bg-white p-4" > -
    -
    + {/* Enroulée, sinon la barre d'actions déborde de 115 px à + 320 px de large : elle était en shrink-0, donc elle refusait + de passer à la ligne. */} +
    +

    {slot.title}

    {slot.startAt && (

    {formatDateTime(slot.startAt)} {slot.endAt && ( <> - + {formatDateTime(slot.endAt)} )} @@ -253,7 +256,7 @@ export function SlotManager({ /> )}

    -
    +
    0 ? "amber" : "green"}> {slot.signups.length} inscrit {slot.signups.length > 1 ? "s" : ""} sur {slot.capacity} @@ -267,7 +270,7 @@ export function SlotManager({ editingId === slot.id ? null : slot.id, ) } - className="text-xs font-medium text-slate-400 hover:text-brand-700" + className="min-h-8 rounded px-1 text-xs font-semibold text-slate-600 transition-colors hover:text-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" > {editingId === slot.id ? "Fermer" : "Modifier"} @@ -275,14 +278,14 @@ export function SlotManager({ type="button" onClick={() => duplicateSlot(slot)} disabled={busySlotId === slot.id} - className="text-xs font-medium text-slate-400 hover:text-brand-700 disabled:opacity-50" + className="min-h-8 rounded px-1 text-xs font-semibold text-slate-600 transition-colors hover:text-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50" > {busySlotId === slot.id ? "Copie…" : "Dupliquer"} @@ -386,26 +389,51 @@ export function SlotManager({ />
    + {/* Les inscrits sont affichés, pas repliés. + Ils l'étaient derrière un
    fermé, et leurs + coordonnées étaient écrites en slate-400 sur slate-50 — + 2,45:1, soit la moitié du minimum lisible. Qui cherchait un + numéro de téléphone concluait qu'il n'avait pas été + enregistré, alors qu'il l'était depuis le début. + Le téléphone et l'adresse sont cliquables, comme du côté + des réunions : on consulte cette liste la veille, depuis un + téléphone, pour joindre quelqu'un. */} {slot.signups.length > 0 && ( -
    - - Voir les {slot.signups.length} personne +
    +

    + {slot.signups.length} personne {slot.signups.length > 1 ? "s" : ""} inscrite {slot.signups.length > 1 ? "s" : ""} -

    -
      +

      +
        {slot.signups.map((signup) => (
      • - - {signup.name} - {(signup.email || signup.phone) && ( - - {[signup.email, signup.phone] - .filter(Boolean) - .join(" · ")} + + + {signup.name} + + {signup.phone && ( + + {signup.phone} + + )} + {signup.email && ( + + {signup.email} + + )} + {!signup.phone && !signup.email && ( + + Aucune coordonnée laissée )} @@ -413,15 +441,16 @@ export function SlotManager({ )}
      • ))}
      -
    +
    )} ); diff --git a/src/lib/data.ts b/src/lib/data.ts index c4ebfce..d15c8fc 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -116,7 +116,13 @@ export const getEventByShareToken = cache(async (token: string) => { // les créneaux sans horaire ferment la liste, dans leur ordre de // création. orderBy: [asc(volunteerSlots.startAt), asc(volunteerSlots.createdAt)], - with: { signups: true }, + // Seul le NOMBRE d'inscrits sert ici, pour afficher les places + // restantes. On ne charge donc que l'identifiant : cette requête + // alimente une page publique, et charger la ligne entière — nom, + // e-mail, téléphone, jeton d'annulation — laissait la fuite à un + // `.map()` distrait de distance. La colonne blanche coûte moins cher + // qu'une règle écrite en commentaire. + with: { signups: { columns: { id: true } } }, }, }, }); diff --git a/src/lib/mcp/register-core-tools.ts b/src/lib/mcp/register-core-tools.ts index 99c695e..2487545 100644 --- a/src/lib/mcp/register-core-tools.ts +++ b/src/lib/mcp/register-core-tools.ts @@ -614,7 +614,22 @@ export function registerCoreTools( const slots = await db.query.volunteerSlots.findMany({ where: eq(volunteerSlots.eventId, eventId), orderBy: [asc(volunteerSlots.startAt), asc(volunteerSlots.createdAt)], - with: { signups: true }, + // Colonnes énumérées, comme le fait getEventWithDetails : `signups: + // true` renvoyait aussi `cancelToken`, qui n'est pas une donnée mais + // un pouvoir — qui le détient peut désinscrire la personne sans être + // authentifié. Un outil qui sert à lire des coordonnées n'a aucune + // raison de le distribuer. + with: { + signups: { + columns: { + id: true, + name: true, + email: true, + phone: true, + createdAt: true, + }, + }, + }, }); return toolResult({ eventId, slots }); },