Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
- 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:
co-authored by
Claude Sonnet 5
parent
a2b748566b
commit
096b7cd26a
+6
-3
@@ -9,9 +9,12 @@ server {
|
||||
server_tokens off;
|
||||
|
||||
# RGPD / sécurité : les liens magiques (`/connexion?token=…`, un jeton de
|
||||
# connexion à usage unique) transitent par cette route. Pas de journal
|
||||
# d'accès ici — un jeton en clair dans un log de conteneur serait un vrai
|
||||
# moyen de se connecter à la place de quelqu'un pendant sa durée de vie.
|
||||
# connexion à usage unique) et le lien d'inscription partagé par la
|
||||
# coordination (`/inscription/<access_token>`, voir CLAUDE.md § 2,
|
||||
# `FormTemplate`) transitent tous les deux par cette route. Pas de
|
||||
# journal d'accès ici — un jeton en clair dans un log de conteneur
|
||||
# serait un vrai moyen de se connecter à la place de quelqu'un (ou de
|
||||
# deviner le lien d'inscription) pendant sa durée de vie.
|
||||
access_log off;
|
||||
|
||||
# Postures de base pour une appli publique : pas de sniffing de type MIME,
|
||||
|
||||
@@ -94,7 +94,9 @@ async function checkPublicForm() {
|
||||
await api.forms.setFields(e, {
|
||||
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT', 'WILLING_TO_PAY_FEE'],
|
||||
});
|
||||
const config = await api.forms.getPublicConfig(e);
|
||||
const template = await api.forms.getTemplate(e);
|
||||
const formToken = template.access_token;
|
||||
const config = await api.forms.getPublicConfig(e, formToken);
|
||||
if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut');
|
||||
if (config.positions.length !== positions.length) {
|
||||
throw new Error('formulaire public : postes manquants dans la config publique');
|
||||
@@ -107,7 +109,7 @@ async function checkPublicForm() {
|
||||
// qu'exécuté ici) ; seul le texte d'introduction et la config SumUp sont
|
||||
// vérifiables ici.
|
||||
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
|
||||
const configWithDescription = await api.forms.getPublicConfig(e);
|
||||
const configWithDescription = await api.forms.getPublicConfig(e, formToken);
|
||||
if (configWithDescription.description !== 'Bienvenue au festival de test !') {
|
||||
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
|
||||
}
|
||||
@@ -127,7 +129,7 @@ async function checkPublicForm() {
|
||||
amount: 15,
|
||||
});
|
||||
await api.forms.paymentOptions.create(e, { label: 'Mécénat', amount: 50 });
|
||||
const configWithSumup = await api.forms.getPublicConfig(e);
|
||||
const configWithSumup = await api.forms.getPublicConfig(e, formToken);
|
||||
if (!configWithSumup.sumup_enabled || configWithSumup.payment_options.length !== 2) {
|
||||
throw new Error('formulaire public : la configuration SumUp ne revient pas correctement');
|
||||
}
|
||||
@@ -136,7 +138,7 @@ async function checkPublicForm() {
|
||||
let tooManyArtistsRejectedWith = 'non';
|
||||
if (sameDay.length >= 2) {
|
||||
try {
|
||||
await api.forms.submitPublic(e, {
|
||||
await api.forms.submitPublic(e, formToken, {
|
||||
first_name: 'Test',
|
||||
last_name: 'Smoke',
|
||||
hcaptcha_token: 'dev',
|
||||
@@ -151,7 +153,7 @@ async function checkPublicForm() {
|
||||
let tooManyPositionsRejectedWith = 'non';
|
||||
if (positions.length >= 3) {
|
||||
try {
|
||||
await api.forms.submitPublic(e, {
|
||||
await api.forms.submitPublic(e, formToken, {
|
||||
first_name: 'Test',
|
||||
last_name: 'Smoke',
|
||||
hcaptcha_token: 'dev',
|
||||
@@ -165,7 +167,7 @@ async function checkPublicForm() {
|
||||
|
||||
// Paiement en ligne choisi : rien en base tant que la « banque » (mock) ne
|
||||
// confirme pas — voir CLAUDE.md § 2, Paiement de la cotisation.
|
||||
const result = await api.forms.submitPublic(e, {
|
||||
const result = await api.forms.submitPublic(e, formToken, {
|
||||
first_name: 'test',
|
||||
last_name: 'smoke',
|
||||
hcaptcha_token: 'dev',
|
||||
@@ -430,7 +432,9 @@ await checkGridLayout().catch((err) => fail('checkGridLayout', err));
|
||||
|
||||
renderPublic('/connexion');
|
||||
renderPublic('/connexion/coordination');
|
||||
renderPublic('/inscription'); // formulaire public, hors de <RequireAuth/>
|
||||
// formulaire public, hors de <RequireAuth/> — jeton fixe du mock (voir
|
||||
// frontend/src/api/mock/dataset.ts, buildMockState).
|
||||
renderPublic('/inscription/demo-access-token');
|
||||
|
||||
if (coordinator) {
|
||||
setAuthToken(coordinatorToken);
|
||||
|
||||
+40
-13
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const routes: RouteObject[] = [
|
||||
// Formulaire public d'inscription : aucun compte requis, donc hors de
|
||||
// <RequireAuth/> — voir app/api/forms.py (les seuls endpoints publics de
|
||||
// toute l'API).
|
||||
{ path: '/inscription', element: <PublicSignupPage /> },
|
||||
{ path: '/inscription/:token', element: <PublicSignupPage /> },
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cx } from '@/lib/cx';
|
||||
import { useTooltipPosition } from '@/lib/useTooltipPosition';
|
||||
|
||||
// Primitives visuelles du design « Bénévoles Festival » : bordures franches,
|
||||
// ombres portées dures, libellés capitales. Aucun texte en dur ici — les
|
||||
@@ -196,20 +197,27 @@ export function HelpTip({
|
||||
side?: 'top' | 'bottom';
|
||||
className?: string;
|
||||
}) {
|
||||
const { anchorRef, tooltipRef, pos, reposition } = useTooltipPosition<
|
||||
HTMLButtonElement,
|
||||
HTMLSpanElement
|
||||
>(side);
|
||||
|
||||
return (
|
||||
<span className={cx('group relative inline-flex align-middle', className)}>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
onMouseEnter={reposition}
|
||||
onFocus={reposition}
|
||||
className="flex h-[15px] w-[15px] flex-none cursor-help items-center justify-center border-[1.5px] border-ink bg-cream font-mono text-[9px] font-bold leading-none text-ink hover:bg-yellow focus-visible:bg-yellow focus-visible:outline-none"
|
||||
aria-label={text}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<span
|
||||
className={cx(
|
||||
'pointer-events-none absolute left-1/2 z-20 w-max max-w-[260px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
side === 'top' ? 'bottom-full mb-[6px]' : 'top-full mt-[6px]',
|
||||
)}
|
||||
ref={tooltipRef}
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
className="pointer-events-none fixed z-30 w-max max-w-[260px] border-2 border-ink bg-ink px-[9px] py-[7px] font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
|
||||
@@ -223,6 +223,8 @@
|
||||
"config.vol.importButton2": "IMPORTER UN FICHIER",
|
||||
"config.vol.exportButton": "EXPORTER EN EXCEL",
|
||||
"config.vol.exporting": "EXPORT…",
|
||||
"config.vol.mecenatReceiptsButton": "REÇUS MÉCÉNAT (PDF)",
|
||||
"config.vol.mecenatReceiptsHint": "Génère un PDF regroupant le reçu fiscal (cerfa) de chaque bénévole ayant payé sa cotisation en mécénat, avec ses coordonnées et le montant déjà remplis. Les informations de l'association (encadré du haut) restent à compléter à la main avant remise.",
|
||||
"config.vol.search": "Rechercher un nom, un email…",
|
||||
"config.vol.emptyList": "Aucun bénévole pour l'instant. Ajoutez-en un ou importez un fichier.",
|
||||
"config.vol.noMatch": "Aucun bénévole ne correspond à la recherche.",
|
||||
@@ -391,9 +393,11 @@
|
||||
"form.field.photo": "Une magnifique photo de toi :",
|
||||
"form.field.comment": "Commentaire",
|
||||
"form.link.title": "Lien du formulaire",
|
||||
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire.",
|
||||
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire, mais lui seul : sans ce lien précis, impossible de tomber sur le formulaire.",
|
||||
"form.link.copy": "Copier",
|
||||
"form.link.copied": "Copié ✓",
|
||||
"form.link.regenerate": "Régénérer le lien",
|
||||
"form.link.regenerate.help": "Remplace le lien par un nouveau — utile si l'ancien a fuité. L'ancien lien cesse aussitôt de fonctionner ; il faudra repartager le nouveau.",
|
||||
"form.status.title": "Inscriptions",
|
||||
"form.status.open": "OUVERTES",
|
||||
"form.status.closed": "FERMÉES",
|
||||
@@ -472,5 +476,6 @@
|
||||
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
||||
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
||||
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
||||
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes."
|
||||
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes.",
|
||||
"mecenat.no_receipts": "Aucun bénévole n'a payé la cotisation en mécénat pour cette édition."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
interface Position {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position (repère viewport, à utiliser avec `position: fixed`) d'une bulle
|
||||
* ancrée sur un élément déclencheur, recalculée à chaque ouverture et bornée
|
||||
* à l'intérieur de l'écran (marge `margin`) — un centrage purement CSS
|
||||
* (`left-1/2 -translate-x-1/2` en position `absolute`) laisse la bulle
|
||||
* déborder de l'écran quand le déclencheur est près d'un bord (colonne de
|
||||
* droite, grille défilante…), et la fait passer derrière tout élément
|
||||
* `position`/`z-index` placé entre elle et la racine (en-têtes figés d'une
|
||||
* grille défilante, par exemple) : `fixed` s'affranchit des deux à la fois.
|
||||
*
|
||||
* `side` n'est qu'une préférence verticale : si la place manque de ce
|
||||
* côté-là, la bulle bascule automatiquement de l'autre.
|
||||
*/
|
||||
export function useTooltipPosition<Anchor extends HTMLElement, Tooltip extends HTMLElement>(
|
||||
side: 'top' | 'bottom' = 'top',
|
||||
margin = 8,
|
||||
) {
|
||||
const anchorRef = useRef<Anchor>(null);
|
||||
const tooltipRef = useRef<Tooltip>(null);
|
||||
const [pos, setPos] = useState<Position>({ left: -9999, top: -9999 });
|
||||
|
||||
const reposition = () => {
|
||||
const anchor = anchorRef.current;
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!anchor || !tooltip) return;
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(anchorRect.left + anchorRect.width / 2 - tooltipRect.width / 2, margin),
|
||||
window.innerWidth - tooltipRect.width - margin,
|
||||
);
|
||||
|
||||
const above = anchorRect.top - tooltipRect.height - 6;
|
||||
const below = anchorRect.bottom + 6;
|
||||
let top = side === 'top' ? above : below;
|
||||
if (side === 'top' && above < margin) top = below;
|
||||
if (side === 'bottom' && below + tooltipRect.height > window.innerHeight - margin) top = above;
|
||||
|
||||
setPos({ left, top });
|
||||
};
|
||||
|
||||
return { anchorRef, tooltipRef, pos, reposition };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useFormLogoUrl,
|
||||
useFormPaymentOptions,
|
||||
useFormTemplate,
|
||||
useRegenerateFormToken,
|
||||
useSetFormFields,
|
||||
useUpdateFormPaymentOption,
|
||||
useUpdateFormTemplate,
|
||||
@@ -70,7 +71,12 @@ export function FormBuilderPage() {
|
||||
const saveDescription = useUpdateFormTemplate();
|
||||
const saveSumUp = useUpdateFormTemplate();
|
||||
const uploadLogo = useUploadFormLogo();
|
||||
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt);
|
||||
const regenerateToken = useRegenerateFormToken();
|
||||
const logoUrl = useFormLogoUrl(
|
||||
template.data?.has_logo ?? false,
|
||||
template.dataUpdatedAt,
|
||||
template.data?.access_token ?? null,
|
||||
);
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
|
||||
@@ -102,7 +108,7 @@ export function FormBuilderPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const link = `${window.location.origin}/inscription`;
|
||||
const link = `${window.location.origin}/inscription/${template.data?.access_token ?? ''}`;
|
||||
const copyLink = () => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
setCopied(true);
|
||||
@@ -131,6 +137,22 @@ export function FormBuilderPage() {
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<Btn
|
||||
variant="ghost"
|
||||
onClick={() => regenerateToken.mutate()}
|
||||
disabled={regenerateToken.isPending}
|
||||
>
|
||||
{regenerateToken.isPending ? t('form.saving') : t('form.link.regenerate')}
|
||||
</Btn>
|
||||
<HelpTip text={t('form.link.regenerate.help')} />
|
||||
{regenerateToken.isError ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||
{errorMessage(regenerateToken.error)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-3 border-t-2 border-ink pt-3">
|
||||
<span className="font-display text-[11px] font-extrabold uppercase tracking-wide">
|
||||
{t('form.status.title')}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { hourRangeLabel, shortDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { useTooltipPosition } from '@/lib/useTooltipPosition';
|
||||
|
||||
export function GrillePage() {
|
||||
const matrix = useMatrix();
|
||||
@@ -437,22 +438,19 @@ function ShiftCell({
|
||||
const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff;
|
||||
const isOk = !undefinedNeed && !isCrit && !isOver && !isInc;
|
||||
const glyph = undefinedNeed ? '·' : isCrit ? '▲' : isOver ? '▼' : isInc ? '◧' : '✓';
|
||||
const { anchorRef, tooltipRef, pos, reposition } = useTooltipPosition<
|
||||
HTMLDivElement,
|
||||
HTMLDivElement
|
||||
>('bottom');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={anchorRef}
|
||||
onClick={() => onSelect(shift.shift_id)}
|
||||
onMouseEnter={reposition}
|
||||
style={{ gridColumn, gridRow }}
|
||||
className={cx(
|
||||
// `relative z-0` (plutôt que `relative` seul) crée un contexte
|
||||
// d'empilement local : l'infobulle interne (`z-20`, voir plus bas)
|
||||
// ne rivalise qu'avec les autres enfants de cette cellule, jamais
|
||||
// avec les en-têtes figés de la grille (z-10 à 21) — sans ça, un
|
||||
// survol déclenché par la molette pendant un défilement (le
|
||||
// curseur reste immobile à l'écran, ce sont les cellules qui
|
||||
// défilent dessous) pouvait faire passer l'infobulle au-dessus de
|
||||
// la ligne d'en-tête « Affectés / idéal » pendant sa transition de
|
||||
// disparition.
|
||||
'group relative z-0 flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
|
||||
'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
|
||||
undefinedNeed && 'bg-sand-dark hover:bg-sand',
|
||||
isCrit && 'bg-hatch-orange',
|
||||
isOver && 'bg-hatch-mauve hover:bg-mauve-hover',
|
||||
@@ -463,7 +461,15 @@ function ShiftCell({
|
||||
<span className="font-mono text-[13px] font-bold">{assigned}</span>
|
||||
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
|
||||
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>{glyph}</span>
|
||||
<div className="pointer-events-none absolute left-1/2 top-full z-[100] mt-[4px] w-max max-w-[220px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
// `fixed` (plutôt qu'`absolute` ancré sur la cellule) : la bulle
|
||||
// s'affranchit à la fois du cadrage de la zone de défilement de la
|
||||
// grille (`overflow-auto`, qui la découpait) et de la pile locale
|
||||
// des en-têtes figés (qui la faisait passer dessous, invisible) —
|
||||
// voir `useTooltipPosition`. Recalculée à chaque survol.
|
||||
className="pointer-events-none fixed z-30 w-max max-w-[220px] border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
|
||||
<div className="mb-[1px] font-mono text-[9px] font-bold text-cream opacity-80">
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ApiError } from '@/api';
|
||||
import {
|
||||
@@ -140,15 +141,20 @@ export function PublicSignupPage() {
|
||||
// `typeof window` : rendu aussi en SSR (voir scripts/smoke.tsx), où
|
||||
// `window` n'existe pas — contrairement aux autres pages, rien ici ne
|
||||
// dépend d'abord du chargement d'une requête pour en différer l'accès.
|
||||
const token = useMemo(
|
||||
const pendingToken = useMemo(
|
||||
() =>
|
||||
typeof window === 'undefined'
|
||||
? null
|
||||
: new URLSearchParams(window.location.search).get('paiement'),
|
||||
[],
|
||||
);
|
||||
if (token) return <PaymentConfirmationView token={token} />;
|
||||
return <PublicSignupForm />;
|
||||
// Jeton d'accès au formulaire (`/inscription/:token`, voir CLAUDE.md § 2,
|
||||
// FormTemplate.access_token) — distinct du jeton de paiement en attente
|
||||
// ci-dessus, qui n'en a pas besoin (déjà sécurisé par son propre UUID).
|
||||
const { token: accessToken } = useParams<{ token: string }>();
|
||||
if (pendingToken) return <PaymentConfirmationView token={pendingToken} />;
|
||||
if (!accessToken) return null;
|
||||
return <PublicSignupForm accessToken={accessToken} />;
|
||||
}
|
||||
|
||||
/** Interrogée en direct (voir CLAUDE.md § 2, Paiement de la cotisation)
|
||||
@@ -334,10 +340,10 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string
|
||||
);
|
||||
}
|
||||
|
||||
function PublicSignupForm() {
|
||||
const config = usePublicFormConfig();
|
||||
const submit = useSubmitPublicForm();
|
||||
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt);
|
||||
function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
const config = usePublicFormConfig(accessToken);
|
||||
const submit = useSubmitPublicForm(accessToken);
|
||||
const logoUrl = useFormLogoUrl(config.data?.has_logo ?? false, config.dataUpdatedAt, accessToken);
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useDeleteVolunteer,
|
||||
useEdition,
|
||||
useExecuteImport,
|
||||
useExportMecenatReceipts,
|
||||
useExportVolunteers,
|
||||
usePositions,
|
||||
useReplaceAvailability,
|
||||
@@ -489,6 +490,7 @@ export function ConfigVolunteers() {
|
||||
const updateVolunteer = useUpdateVolunteer();
|
||||
const deleteVolunteer = useDeleteVolunteer();
|
||||
const exportVolunteers = useExportVolunteers();
|
||||
const exportMecenatReceipts = useExportMecenatReceipts();
|
||||
|
||||
const [mode, setMode] = useState<Mode>('list');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@@ -535,6 +537,19 @@ export function ConfigVolunteers() {
|
||||
// Erreur exposée via exportVolunteers.isError / .error, affichée plus bas.
|
||||
}
|
||||
};
|
||||
const handleExportMecenatReceipts = async () => {
|
||||
try {
|
||||
const blob = await exportMecenatReceipts.mutateAsync();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'recus-mecenat.pdf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Erreur exposée via exportMecenatReceipts.isError / .error, affichée plus bas.
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'add') {
|
||||
return <AddVolunteerForm onClose={() => setMode('list')} />;
|
||||
@@ -577,10 +592,25 @@ export function ConfigVolunteers() {
|
||||
>
|
||||
↑ {exportVolunteers.isPending ? t('config.vol.exporting') : t('config.vol.exportButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportMecenatReceipts}
|
||||
disabled={exportMecenatReceipts.isPending}
|
||||
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-yellow disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
↑{' '}
|
||||
{exportMecenatReceipts.isPending
|
||||
? t('config.vol.exporting')
|
||||
: t('config.vol.mecenatReceiptsButton')}
|
||||
</button>
|
||||
<HelpTip text={t('config.vol.mecenatReceiptsHint')} />
|
||||
</div>
|
||||
{exportVolunteers.isError ? (
|
||||
<ErrorNote error={exportVolunteers.error} />
|
||||
) : null}
|
||||
{exportMecenatReceipts.isError ? (
|
||||
<ErrorNote error={exportMecenatReceipts.error} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
|
||||
<div className="font-mono text-[24px] font-bold">{total}</div>
|
||||
|
||||
Reference in New Issue
Block a user