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
@@ -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({
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<VolunteerRead>;
|
||||
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: {
|
||||
/** Fenêtres déclarées par un bénévole. */
|
||||
list(editionId: string, volunteerId: string): Promise<AvailabilityRead[]>;
|
||||
|
||||
Reference in New Issue
Block a user