Front : donner un accès de connexion à un bénévole (comptes manquants)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
fb6e26eddb
commit
e405590bbc
@@ -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
|
La coordination crée les comptes via `POST /editions/{id}/accounts` : un
|
||||||
`COORDINATOR` **exige** un `password` (≥ 10) ; un `LEAD` / `VOLUNTEER` le
|
`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/`)
|
### Emails (`app/email/`)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
|
|
||||||
import { EDITION_ID, api } from './index';
|
import { EDITION_ID, api } from './index';
|
||||||
import type {
|
import type {
|
||||||
|
AccountCreate,
|
||||||
ArtistCreate,
|
ArtistCreate,
|
||||||
ArtistUpdate,
|
ArtistUpdate,
|
||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
@@ -68,6 +69,10 @@ export function usePositions() {
|
|||||||
return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) });
|
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). */
|
/** Fiche d'un bénévole (un bénévole connecté peut lire la sienne). */
|
||||||
export function useVolunteer(id: string | null | undefined) {
|
export function useVolunteer(id: string | null | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -151,6 +156,7 @@ function useInvalidate() {
|
|||||||
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
|
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
|
||||||
availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }),
|
availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }),
|
||||||
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
|
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
|
||||||
|
accounts: () => client.invalidateQueries({ queryKey: ['accounts', edition] }),
|
||||||
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
|
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
|
||||||
shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }),
|
shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }),
|
||||||
planning: () => {
|
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() {
|
export function useCreateVolunteer() {
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AccountCreate,
|
||||||
ArtistCreate,
|
ArtistCreate,
|
||||||
ArtistUpdate,
|
ArtistUpdate,
|
||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
@@ -109,6 +110,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
remove: (editionId, id) =>
|
remove: (editionId, id) =>
|
||||||
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
|
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: {
|
availability: {
|
||||||
list: (editionId, volunteerId) =>
|
list: (editionId, volunteerId) =>
|
||||||
request(`${base(editionId)}/volunteers/${volunteerId}/availability`),
|
request(`${base(editionId)}/volunteers/${volunteerId}/availability`),
|
||||||
|
|||||||
@@ -730,6 +730,46 @@ export function createMockApi(): PlanningApi {
|
|||||||
state.volunteers = state.volunteers.filter((x) => x.id !== id);
|
state.volunteers = state.volunteers.filter((x) => x.id !== id);
|
||||||
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
|
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
|
||||||
state.availability.delete(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);
|
return delay(undefined);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -409,6 +409,14 @@ export interface AccountRead {
|
|||||||
updated_at: string;
|
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 {
|
export interface TokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AccountCreate,
|
||||||
AccountRead,
|
AccountRead,
|
||||||
ArtistCreate,
|
ArtistCreate,
|
||||||
ArtistRead,
|
ArtistRead,
|
||||||
@@ -68,6 +69,12 @@ export interface PlanningApi {
|
|||||||
update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>;
|
update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>;
|
||||||
remove(editionId: string, id: string): Promise<void>;
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
};
|
};
|
||||||
|
accounts: {
|
||||||
|
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
|
||||||
|
list(editionId: string): Promise<AccountRead[]>;
|
||||||
|
create(editionId: string, body: AccountCreate): Promise<AccountRead>;
|
||||||
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
|
};
|
||||||
availability: {
|
availability: {
|
||||||
/** Fenêtres déclarées par un bénévole. */
|
/** Fenêtres déclarées par un bénévole. */
|
||||||
list(editionId: string, volunteerId: string): Promise<AvailabilityRead[]>;
|
list(editionId: string, volunteerId: string): Promise<AvailabilityRead[]>;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
"assignment.wrong_edition": "Bénévole ou créneau hors de cette édition",
|
"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.ends_not_after_starts": "La fin doit être après le début",
|
||||||
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
|
"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_credentials": "Email ou mot de passe incorrect",
|
||||||
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
|
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
|
||||||
"role.coordinator": "Coordination",
|
"role.coordinator": "Coordination",
|
||||||
@@ -223,8 +227,15 @@
|
|||||||
"config.vol.field.email": "Email (lien magique)",
|
"config.vol.field.email": "Email (lien magique)",
|
||||||
"config.vol.field.phone": "Téléphone",
|
"config.vol.field.phone": "Téléphone",
|
||||||
"config.vol.field.comment": "Commentaire",
|
"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.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.create": "CRÉER LE BÉNÉVOLE",
|
||||||
"config.vol.requiredNames": "Le prénom et le nom sont obligatoires.",
|
"config.vol.requiredNames": "Le prénom et le nom sont obligatoires.",
|
||||||
"config.vol.wiz.title": "IMPORTER UN FICHIER",
|
"config.vol.wiz.title": "IMPORTER UN FICHIER",
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
import {
|
import {
|
||||||
|
useAccounts,
|
||||||
useAnalyzeImport,
|
useAnalyzeImport,
|
||||||
useArtists,
|
useArtists,
|
||||||
|
useCreateAccount,
|
||||||
useCreateVolunteer,
|
useCreateVolunteer,
|
||||||
|
useDeleteAccount,
|
||||||
useDeleteVolunteer,
|
useDeleteVolunteer,
|
||||||
useEdition,
|
useEdition,
|
||||||
useExecuteImport,
|
useExecuteImport,
|
||||||
@@ -69,6 +72,70 @@ function positionOptions(positions: PositionRead[] | undefined): WishOption[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Sélecteur de vœux : liste déroulante + puces retirables. */
|
/** 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 (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>
|
||||||
|
{t('config.vol.access.title')} <HelpTip text={t('config.vol.access.hint')} />
|
||||||
|
</FieldLabel>
|
||||||
|
{account ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 border-2 border-ink bg-cream p-[9px]">
|
||||||
|
<span className="min-w-0 truncate font-mono text-[10px]">
|
||||||
|
{t('config.vol.access.active', { email: account.email })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={deleteAccount.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t('config.vol.access.revokeConfirm'))) {
|
||||||
|
deleteAccount.mutate(account.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-none cursor-pointer border-2 border-ink bg-paper px-[8px] py-[5px] font-display text-[9.5px] font-extrabold uppercase tracking-wide hover:bg-orange disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('config.vol.access.revoke')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!email || createAccount.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
email && createAccount.mutate({ email, role: 'VOLUNTEER', volunteer_id: volunteerId })
|
||||||
|
}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[7px] font-display text-[10.5px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createAccount.isPending ? t('app.loading') : t('config.vol.access.grant')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!account && !email ? (
|
||||||
|
<div className="mt-1 font-mono text-[9.5px] opacity-60">
|
||||||
|
{t('config.vol.access.needEmail')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{createAccount.isError ? <ErrorNote error={createAccount.error} /> : null}
|
||||||
|
{deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WishPicker({
|
function WishPicker({
|
||||||
label,
|
label,
|
||||||
options,
|
options,
|
||||||
@@ -342,6 +409,8 @@ export function ConfigVolunteers() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||||
|
|
||||||
<WishPicker
|
<WishPicker
|
||||||
label={t('config.vol.field.artists')}
|
label={t('config.vol.field.artists')}
|
||||||
options={artistOpts}
|
options={artistOpts}
|
||||||
|
|||||||
Reference in New Issue
Block a user