From e405590bbc6f17efc5b81edf95d4498f9dd6330a Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 15 Sep 2026 20:22:56 +0200 Subject: [PATCH] =?UTF-8?q?Front=20:=20donner=20un=20acc=C3=A8s=20de=20con?= =?UTF-8?q?nexion=20=C3=A0=20un=20b=C3=A9n=C3=A9vole=20(comptes=20manquant?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause du « je n'ajoute un bénévole avec son mail, je ne reçois aucun mail » : renseigner un email sur la fiche d'un bénévole ne crée jamais de compte (`Account`). `POST .../auth/magic-link` cherche un compte par email et ne fait rien s'il n'en trouve pas (202 quoi qu'il arrive, par conception, pour ne jamais révéler si un compte existe) — silencieusement, sans erreur. L'API `POST/GET/DELETE /editions/{id}/accounts` existait déjà côté backend (testée) mais n'avait jamais été branchée au frontend. - `api/schema.ts` / `types.ts` / `http-client.ts` / `hooks.ts` : nouvelle surface `accounts` (list/create/remove) + `useAccounts`, `useCreateAccount`, `useDeleteAccount`. - `mock-client.ts` : même surface (règles miroir du backend : email unique, mot de passe interdit hors COORDINATOR) ; la suppression d'un bénévole supprime aussi son compte (cascade RGPD, alignée sur le backend). - `ConfigVolunteers.tsx` : section « Accès de connexion » sur la fiche du bénévole — bouton pour donner l'accès (crée un compte VOLUNTEER, lien magique) si un email est renseigné, ou le révoquer si un accès existe déjà. - Corrigé au passage : `config.vol.addHint` affirmait qu'un email donnait l'accès automatiquement — c'était faux, texte mis à jour. - CLAUDE.md à jour (section authentification). Vérifié : typecheck, lint, build, smoke (7 routes) OK. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- CLAUDE.md | 9 ++- frontend/src/api/hooks.ts | 22 ++++++ frontend/src/api/http-client.ts | 8 +++ frontend/src/api/mock/mock-client.ts | 40 +++++++++++ frontend/src/api/schema.ts | 8 +++ frontend/src/api/types.ts | 7 ++ frontend/src/i18n/locales/fr.json | 13 +++- .../src/pages/config/ConfigVolunteers.tsx | 69 +++++++++++++++++++ 8 files changed, 174 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b9fbf3..6c0312c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,14 @@ Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magi La coordination crée les comptes via `POST /editions/{id}/accounts` : un `COORDINATOR` **exige** un `password` (≥ 10) ; un `LEAD` / `VOLUNTEER` le -**refuse** (lien magique). Un bénévole sans email n'a pas de `Account`. +**refuse** (lien magique). Un bénévole sans email n'a pas de `Account` — et +inversement, **renseigner un email sur la fiche d'un bénévole ne crée aucun +compte** : c'est un champ de la fiche, pas une inscription. Tant qu'aucun +`Account` ne lui est explicitement donné, `auth/magic-link` ne trouve +personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive, +pour ne jamais révéler si un compte existe). Front : bouton « Donner un accès +de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle +cet endpoint avec `role=VOLUNTEER`. ### Emails (`app/email/`) diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index e81cb58..7bbd2c1 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -7,6 +7,7 @@ import { import { EDITION_ID, api } from './index'; import type { + AccountCreate, ArtistCreate, ArtistUpdate, AssignmentCreate, @@ -68,6 +69,10 @@ export function usePositions() { return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) }); } +export function useAccounts() { + return useQuery({ queryKey: ['accounts', edition], queryFn: () => api.accounts.list(edition) }); +} + /** Fiche d'un bénévole (un bénévole connecté peut lire la sienne). */ export function useVolunteer(id: string | null | undefined) { return useQuery({ @@ -151,6 +156,7 @@ function useInvalidate() { volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }), availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }), artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }), + accounts: () => client.invalidateQueries({ queryKey: ['accounts', edition] }), positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }), shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }), planning: () => { @@ -239,6 +245,22 @@ export function useDeleteArtist() { }); } +export function useCreateAccount() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: AccountCreate) => api.accounts.create(edition, body), + onSuccess: invalidate.accounts, + }); +} + +export function useDeleteAccount() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.accounts.remove(edition, id), + onSuccess: invalidate.accounts, + }); +} + export function useCreateVolunteer() { const invalidate = useInvalidate(); return useMutation({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 8fb1829..809bc06 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -1,4 +1,5 @@ import type { + AccountCreate, ArtistCreate, ArtistUpdate, AssignmentCreate, @@ -109,6 +110,13 @@ export function createHttpApi(baseUrl: string): PlanningApi { remove: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), }, + accounts: { + list: (editionId) => request(`${base(editionId)}/accounts`), + create: (editionId, body: AccountCreate) => + request(`${base(editionId)}/accounts`, { method: 'POST', body: JSON.stringify(body) }), + remove: (editionId, id) => + request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }), + }, availability: { list: (editionId, volunteerId) => request(`${base(editionId)}/volunteers/${volunteerId}/availability`), diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 67b5ff7..5af27ba 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -730,6 +730,46 @@ export function createMockApi(): PlanningApi { state.volunteers = state.volunteers.filter((x) => x.id !== id); state.assignments = state.assignments.filter((a) => a.volunteer_id !== id); state.availability.delete(id); + // RGPD : effacement complet, y compris le compte de connexion éventuel. + state.accounts = state.accounts.filter((a) => a.volunteer_id !== id); + return delay(undefined); + }, + }, + + accounts: { + list: () => + delay([...state.accounts].sort((a, b) => a.email.localeCompare(b.email, 'fr'))), + create: (_edition, body) => { + const email = body.email.trim(); + if (state.accounts.some((a) => a.email.toLowerCase() === email.toLowerCase())) { + throw new ApiError(409, 'account.duplicate'); + } + if (body.volunteer_id && !state.volunteers.some((v) => v.id === body.volunteer_id)) { + throw new ApiError(422, 'account.volunteer_wrong_edition'); + } + if (body.role === 'COORDINATOR') { + if (!body.password || body.password.length < 10) { + throw new ApiError(422, 'account.password_required'); + } + } else if (body.password) { + throw new ApiError(422, 'account.password_not_allowed'); + } + const now = touch(); + const account: AccountRead = { + id: `acc${state.seq++}`, + edition_id: state.edition.id, + email, + role: body.role, + volunteer_id: body.volunteer_id ?? null, + auth_method: body.role === 'COORDINATOR' ? 'password' : 'magic_link', + created_at: now, + updated_at: now, + }; + state.accounts.push(account); + return delay(account); + }, + remove: (_edition, id) => { + state.accounts = state.accounts.filter((a) => a.id !== id); return delay(undefined); }, }, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 12bfae4..7f846d2 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -409,6 +409,14 @@ export interface AccountRead { updated_at: string; } +export interface AccountCreate { + email: string; + role: Role; + volunteer_id?: string | null; + /** Requis pour un compte COORDINATOR, interdit sinon. */ + password?: string | null; +} + export interface TokenResponse { access_token: string; token_type: string; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e4813af..e249ebe 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,4 +1,5 @@ import type { + AccountCreate, AccountRead, ArtistCreate, ArtistRead, @@ -68,6 +69,12 @@ export interface PlanningApi { update(editionId: string, id: string, body: VolunteerUpdate): Promise; remove(editionId: string, id: string): Promise; }; + accounts: { + /** Comptes de connexion (mot de passe ou lien magique) de l'édition. */ + list(editionId: string): Promise; + create(editionId: string, body: AccountCreate): Promise; + remove(editionId: string, id: string): Promise; + }; availability: { /** Fenêtres déclarées par un bénévole. */ list(editionId: string, volunteerId: string): Promise; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 0dc6ce0..031af8a 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -15,6 +15,10 @@ "assignment.wrong_edition": "Bénévole ou créneau hors de cette édition", "shift.ends_not_after_starts": "La fin doit être après le début", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition", + "account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole", + "account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition", + "account.password_required": "Un compte coordination requiert un mot de passe", + "account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe", "auth.invalid_credentials": "Email ou mot de passe incorrect", "auth.invalid_token": "Ce lien de connexion est invalide ou expiré", "role.coordinator": "Coordination", @@ -223,8 +227,15 @@ "config.vol.field.email": "Email (lien magique)", "config.vol.field.phone": "Téléphone", "config.vol.field.comment": "Commentaire", + "config.vol.access.title": "Accès de connexion", + "config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.", + "config.vol.access.grant": "Donner un accès de connexion", + "config.vol.access.active": "Accès actif : {email}", + "config.vol.access.revoke": "Révoquer", + "config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?", + "config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.", "config.vol.addTitle": "NOUVEAU BÉNÉVOLE", - "config.vol.addHint": "Le bénévole reçoit un accès par lien magique uniquement si un email est renseigné.", + "config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.", "config.vol.create": "CRÉER LE BÉNÉVOLE", "config.vol.requiredNames": "Le prénom et le nom sont obligatoires.", "config.vol.wiz.title": "IMPORTER UN FICHIER", diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index a182b2d..741d308 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -2,9 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ApiError } from '@/api'; import { + useAccounts, useAnalyzeImport, useArtists, + useCreateAccount, useCreateVolunteer, + useDeleteAccount, useDeleteVolunteer, useEdition, useExecuteImport, @@ -69,6 +72,70 @@ function positionOptions(positions: PositionRead[] | undefined): WishOption[] { } /** Sélecteur de vœux : liste déroulante + puces retirables. */ +/** + * Accès de connexion (lien magique) du bénévole. Ajouter un email sur sa + * fiche ne crée aucun compte : sans ce geste explicite, `auth/magic-link` ne + * trouve personne à qui écrire et ne fait rien (par conception, pour ne + * jamais révéler si un email existe). + */ +function VolunteerAccessSection({ + volunteerId, + email, +}: { + volunteerId: string; + email: string | null; +}) { + const accounts = useAccounts(); + const createAccount = useCreateAccount(); + const deleteAccount = useDeleteAccount(); + const account = accounts.data?.find((a) => a.volunteer_id === volunteerId); + + return ( +
+ + {t('config.vol.access.title')} + + {account ? ( +
+ + {t('config.vol.access.active', { email: account.email })} + + +
+ ) : ( + + )} + {!account && !email ? ( +
+ {t('config.vol.access.needEmail')} +
+ ) : null} + {createAccount.isError ? : null} + {deleteAccount.isError ? : null} +
+ ); +} + function WishPicker({ label, options, @@ -342,6 +409,8 @@ export function ConfigVolunteers() { /> + +