Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
ci-cd / build-backend (push) Successful in 1m12s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

- Lien d'inscription protégé par un jeton (FormTemplate.access_token,
  /inscription/<token>), avec régénération depuis l'écran de configuration.
- Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en
  mécénat, donateur/montant (chiffres et toutes lettres en français,
  app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel.
- Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée.
- Infobulles (HelpTip et détail de cellule de la grille des besoins)
  repositionnées en position: fixed via JS, bornées à l'écran.
- Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une
  URL (fuyait le jeton d'inscription), sujet retiré du log de remise des
  emails (pouvait porter un nom de bénévole), photo du formulaire public
  revalidée par décodage réel avant stockage, export Excel protégé contre
  l'injection de formule, sondage du statut de paiement limité en
  fréquence, pyjwt mis à jour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-02 07:47:17 +02:00
co-authored by Claude Sonnet 5
parent a2b748566b
commit 096b7cd26a
44 changed files with 1228 additions and 156 deletions
+40 -13
View File
@@ -394,6 +394,14 @@ export function useExportVolunteers() {
});
}
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF pour
* tous les reçus ; même mécanique que `useExportVolunteers`. */
export function useExportMecenatReceipts() {
return useMutation({
mutationFn: () => api.volunteers.exportMecenatReceipts(edition),
});
}
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
export function useReplaceAvailability() {
const invalidate = useInvalidate();
@@ -645,6 +653,16 @@ export function useUploadFormLogo() {
});
}
/** Révoque le lien d'inscription partagé jusqu'ici (nouveau `access_token`)
* — voir `FormTemplateRead.access_token`. */
export function useRegenerateFormToken() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: () => api.forms.regenerateToken(edition),
onSuccess: invalidate,
});
}
/** Types de paiement de la cotisation (COORDINATION) — voir `FormPaymentOptionRead`. */
export function useFormPaymentOptions() {
return useQuery({
@@ -684,19 +702,26 @@ export function useDeleteFormPaymentOption() {
}
/** URL objet (aperçu) du logo du formulaire public, ou `null` si absent.
* Utilisable sans compte connecté (`GET .../form/logo` n'est pas protégé —
* affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de
* `useFormTemplate`) force un nouveau fetch après un changement de logo. */
export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) {
* `GET .../form/logo` exige `token` (voir `_require_access_token` côté
* back-end) — celui de l'édition pour l'écran de configuration
* (COORDINATION, `template.access_token`), celui de l'URL pour la page
* publique (`/inscription/:token`) ; `null` tant qu'il n'est pas connu.
* `refreshKey` (ex. `dataUpdatedAt` de `useFormTemplate`) force un nouveau
* fetch après un changement de logo. */
export function useFormLogoUrl(
hasLogo: boolean,
refreshKey: number | string,
token: string | null,
) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasLogo) {
if (!hasLogo || !token) {
setUrl(null);
return;
}
api.forms.logoBlob(edition).then((blob) => {
api.forms.logoBlob(edition, token).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
@@ -705,22 +730,24 @@ export function useFormLogoUrl(hasLogo: boolean, refreshKey: number | string) {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [hasLogo, refreshKey]);
}, [hasLogo, refreshKey, token]);
return url;
}
/** Configuration publique du formulaire — utilisable sans compte connecté
* (écran `/inscription`, hors de `RequireAuth`). */
export function usePublicFormConfig() {
* (écran `/inscription/:token`, hors de `RequireAuth`). `token` vient de
* l'URL (voir `PublicSignupPage`) ; requis par le back-end en plus de
* l'édition (`_require_access_token`). */
export function usePublicFormConfig(token: string) {
return useQuery({
queryKey: ['publicFormConfig', edition],
queryFn: () => api.forms.getPublicConfig(edition),
queryKey: ['publicFormConfig', edition, token],
queryFn: () => api.forms.getPublicConfig(edition, token),
});
}
export function useSubmitPublicForm() {
export function useSubmitPublicForm(token: string) {
return useMutation({
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body),
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, token, body),
});
}
+13 -3
View File
@@ -157,6 +157,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
exportMecenatReceipts: async (editionId) => {
const blob = await requestBlob(`${base(editionId)}/volunteers/mecenat-receipts`);
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
},
accounts: {
list: (editionId) => request(`${base(editionId)}/accounts`),
@@ -289,10 +294,15 @@ export function createHttpApi(baseUrl: string): PlanningApi {
form.append('file', file);
return request(`${base(editionId)}/form/logo`, { method: 'PUT', body: form });
},
getPublicConfig: (editionId) => request(`${base(editionId)}/form/public`),
logoBlob: (editionId) => requestBlob(`${base(editionId)}/form/logo`),
submitPublic: (editionId, body: PublicFormSubmission) => {
regenerateToken: (editionId) =>
request(`${base(editionId)}/form/regenerate-token`, { method: 'POST' }),
getPublicConfig: (editionId, token) =>
request(`${base(editionId)}/form/public?token=${encodeURIComponent(token)}`),
logoBlob: (editionId, token) =>
requestBlob(`${base(editionId)}/form/logo?token=${encodeURIComponent(token)}`),
submitPublic: (editionId, token, body: PublicFormSubmission) => {
const form = new FormData();
form.append('access_token', token);
form.append('first_name', body.first_name);
form.append('last_name', body.last_name);
form.append('hcaptcha_token', body.hcaptcha_token);
+2 -1
View File
@@ -91,6 +91,7 @@ export interface MockState {
description: string | null;
sumupMerchantCode: string | null;
sumupApiKey: string | null;
accessToken: string;
};
formPaymentOptions: FormPaymentOptionRead[];
/** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2,
@@ -494,7 +495,7 @@ export function buildMockState(): MockState {
},
formTemplate: {
isOpen: true, fields: [], logo: null, logoType: null, description: null,
sumupMerchantCode: null, sumupApiKey: null,
sumupMerchantCode: null, sumupApiKey: null, accessToken: 'demo-access-token',
},
formPaymentOptions: [],
pendingSubmissions: new Map(),
+18 -3
View File
@@ -142,6 +142,7 @@ const formTemplateRead = (): FormTemplateRead => ({
description: state.formTemplate.description,
sumup_merchant_code: state.formTemplate.sumupMerchantCode,
sumup_api_key_set: state.formTemplate.sumupApiKey !== null,
access_token: state.formTemplate.accessToken,
});
const sortedPaymentOptions = (): FormPaymentOptionRead[] =>
@@ -1018,6 +1019,13 @@ export function createMockApi(): PlanningApi {
return (await fetch(dataUrl)).blob();
},
exportXlsx: () => delay(MOCK_XLSX_BLOB()),
exportMecenatReceipts: () => {
const paid = state.volunteers.some(
(v) => v.fee_payment_status === 'PAID' && v.mecenat_address !== null,
);
if (!paid) throw new ApiError(422, 'mecenat.no_receipts');
return delay(MOCK_PDF_BLOB());
},
planning: (_edition, id) => {
if (!state.volunteers.some((v) => v.id === id)) return notFound();
const entries = state.assignments
@@ -1606,11 +1614,17 @@ export function createMockApi(): PlanningApi {
state.formTemplate.logoType = file.type;
return delay<FormTemplateRead>(formTemplateRead());
},
logoBlob: async () => {
regenerateToken: () => {
state.formTemplate.accessToken = `demo-access-token-${state.seq++}`;
return delay<FormTemplateRead>(formTemplateRead());
},
logoBlob: async (_edition, token) => {
if (token !== state.formTemplate.accessToken) notFound();
if (!state.formTemplate.logo) return null;
return (await fetch(state.formTemplate.logo)).blob();
},
getPublicConfig: (): Promise<PublicFormConfig> => {
getPublicConfig: (_edition, token): Promise<PublicFormConfig> => {
if (token !== state.formTemplate.accessToken) notFound();
const fields = state.formTemplate.fields;
return delay({
is_open: state.formTemplate.isOpen,
@@ -1688,7 +1702,8 @@ export function createMockApi(): PlanningApi {
return delay(undefined);
},
},
submitPublic: async (_edition, body: PublicFormSubmission) => {
submitPublic: async (_edition, token, body: PublicFormSubmission) => {
if (token !== state.formTemplate.accessToken) notFound();
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
if (!body.first_name.trim() || !body.last_name.trim()) {
+3
View File
@@ -592,6 +592,9 @@ export interface FormTemplateRead {
sumup_merchant_code: string | null;
/** `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est défini. */
sumup_api_key_set: boolean;
/** Jeton requis pour atteindre `/inscription/:token` — voir
* CLAUDE.md § 2, `FormTemplate`. Visible ici seulement (COORDINATION). */
access_token: string;
}
/** `undefined`/absent = inchangé pour chaque champ. Chaîne vide sur
+16 -5
View File
@@ -93,6 +93,9 @@ export interface PlanningApi {
photoBlob(editionId: string, id: string): Promise<Blob | null>;
/** Export Excel de la liste des bénévoles (COORDINATION). */
exportXlsx(editionId: string): Promise<Blob>;
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
* regroupant tous les reçus (COORDINATION). */
exportMecenatReceipts(editionId: string): Promise<Blob>;
};
accounts: {
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
@@ -175,11 +178,19 @@ export interface PlanningApi {
setFields(editionId: string, body: FormFieldsSet): Promise<FormTemplateRead>;
setOpen(editionId: string, body: FormTemplateUpdate): Promise<FormTemplateRead>;
uploadLogo(editionId: string, file: File): Promise<FormTemplateRead>;
/** Publics — aucune authentification, utilisables sans compte connecté
* (y compris `logoBlob`, affiché sur `/inscription`). */
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
logoBlob(editionId: string): Promise<Blob | null>;
/** Révoque le lien d'inscription partagé jusqu'ici (nouveau
* `access_token`) — l'ancien lien cesse aussitôt de fonctionner. */
regenerateToken(editionId: string): Promise<FormTemplateRead>;
/** Publics — aucune authentification, mais protégés par `token`
* (`FormTemplateRead.access_token`, voir `/inscription/:token`) en plus
* de l'édition : sans lui, aucun de ces appels n'aboutit. */
getPublicConfig(editionId: string, token: string): Promise<PublicFormConfig>;
submitPublic(
editionId: string,
token: string,
body: PublicFormSubmission,
): Promise<FormSubmissionResult>;
logoBlob(editionId: string, token: string): Promise<Blob | null>;
/** Types de paiement de la cotisation (COORDINATION uniquement). */
paymentOptions: {
list(editionId: string): Promise<FormPaymentOptionRead[]>;