Droit à l'image par signature électronique (membre + accompagnants)

Remplace la feuille à imprimer par un recueil électronique du consentement,
construit selon l'article 9 du Code civil (droit à l'image = droit de la
personnalité, personnel) :

- Le membre signe une fois, dans son espace, une autorisation GÉNÉRALE
  couvrant toute photo de lui — plus besoin de valider chaque publication.
  Capture d'une signature dessinée (pad canvas), nom, horodatage et IP :
  une signature électronique simple, preuve de consentement (art. 7 RGPD).
  Étape proposée au premier accès mais NON bloquante (« Plus tard »).
- Un adulte ne pouvant pas consentir pour un autre, l'image des
  accompagnants est recueillie PAR PERSONNE au moment de l'inscription à
  la rencontre : case « autorise / n'autorise pas » par participant +
  attestation du membre d'avoir informé chaque personne et obtenu son
  accord (mineur : représentant légal).
- La publication n'est plus bloquée par le statut de consentement (évite
  le blocage systématique) ; le consentement est la preuve conservée.
- Admin : statut d'autorisation dans la liste des membres (avec la
  signature consultable) et par participant dans la liste des inscrits
  (badges + exports Excel/impression).
- Table image_consents (journal append-only) et colonne
  inscriptions.image_consent, migrations idempotentes.
- Politique de confidentialité mise à jour (consentement par personne,
  fondement art. 9, cas des mineurs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
Claude committed 2026-07-16 20:59:51 +00:00
1 parent a9e01de7a8
commit b34e361cbe
12 files changed
+516 -48

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
// Canonical version + wording of the image-rights consent captured
// electronically from members. Bump the version whenever the wording
// changes so past signatures stay tied to the exact text they agreed to.
export const IMAGE_CONSENT_VERSION = '2026-07-v1';
export const IMAGE_CONSENT_SCOPES = [
{ key: 'site', label: "Site internet de l'association" },
{ key: 'social', label: 'Réseaux sociaux de l’association' },
{ key: 'print', label: 'Supports de communication imprimés' },
];
// The reference text shown to the member and recorded alongside the
// version. Kept short and clear.
export const IMAGE_CONSENT_TEXT =
"J'autorise l'association à fixer, reproduire et diffuser mon image (photographies " +
"prises lors des rencontres et manifestations du club, et photographie de profil de " +
"l'annuaire) sur les supports que je sélectionne ci-dessous. Cette autorisation est " +
"consentie à titre gratuit, pour une durée de 5 ans, pour une diffusion en France et à " +
"l'étranger. Les images ne seront ni cédées à des tiers ni utilisées à des fins " +
"commerciales. Je peux retirer mon consentement à tout moment depuis mon espace membre, " +
"ce qui entraînera le retrait des images concernées dans les meilleurs délais.";
+23 -3
View File
@@ -40,10 +40,15 @@ const MEMBER_SQL = `
m.logo_path, m.photo_path, m.logo_path, m.photo_path,
(u.id IS NOT NULL) AS has_login, (u.id IS NOT NULL) AS has_login,
COALESCE(u.must_change_password, false) AS must_change_password, COALESCE(u.must_change_password, false) AS must_change_password,
CASE WHEN u.must_change_password THEN u.temp_password ELSE NULL END AS temp_password CASE WHEN u.must_change_password THEN u.temp_password ELSE NULL END AS temp_password,
ic.decision AS image_consent, ic.created_at AS image_consent_at
FROM members m FROM members m
LEFT JOIN categories c ON c.id = m.categorie_id LEFT JOIN categories c ON c.id = m.categorie_id
LEFT JOIN users u ON u.member_id = m.id`; LEFT JOIN users u ON u.member_id = m.id
LEFT JOIN LATERAL (
SELECT decision, created_at FROM image_consents
WHERE member_id = m.id ORDER BY created_at DESC LIMIT 1
) ic ON true`;
const RENC_SQL = ` const RENC_SQL = `
SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places,
@@ -53,7 +58,7 @@ const RENC_SQL = `
const INSCR_SQL = ` const INSCR_SQL = `
SELECT i.id, i.nom, i.entreprise, i.email, i.tel, i.statut, i.created_at, SELECT i.id, i.nom, i.entreprise, i.email, i.tel, i.statut, i.created_at,
i.rencontre_id, r.titre AS rencontre i.rencontre_id, i.image_consent, r.titre AS rencontre
FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`; FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`;
async function loadAssociationSettings() { async function loadAssociationSettings() {
@@ -171,6 +176,21 @@ adminRouter.post('/members/:id/reset-access', validate(idParam, 'params'), async
} }
}); });
// The full signed image-rights consent record (latest), including the
// drawn signature — the association's proof of consent.
adminRouter.get('/members/:id/image-consent', validate(idParam, 'params'), async (req, res, next) => {
try {
const result = await query(
`SELECT decision, scopes, signatory_name, signature_png, consent_version, ip, created_at
FROM image_consents WHERE member_id = $1 ORDER BY created_at DESC LIMIT 1`,
[req.params.id]
);
res.json({ consent: result.rows[0] || null });
} catch (err) {
next(err);
}
});
// ---------- Rencontres (admin + moderator) ---------- // ---------- Rencontres (admin + moderator) ----------
adminRouter.get('/rencontres', async (_req, res, next) => { adminRouter.get('/rencontres', async (_req, res, next) => {
try { try {
+41 -3
View File
@@ -2,18 +2,29 @@ import { Router } from 'express';
import { query } from '../db.js'; import { query } from '../db.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js'; import { validate } from '../middleware/validate.js';
import { memberProfileSchema } from '../schemas.js'; import { memberProfileSchema, imageConsentSchema } from '../schemas.js';
import { imageUpload, saveImage, deleteImage } from '../uploads.js'; import { imageUpload, saveImage, deleteImage } from '../uploads.js';
import { IMAGE_CONSENT_VERSION } from '../imageConsent.js';
export const memberRouter = Router(); export const memberRouter = Router();
memberRouter.use(requireAuth('member')); memberRouter.use(requireAuth('member'));
// The current image-rights consent is the latest image_consents row for
// the member (append-only trail). Exposed on the profile so the espace can
// gate on it and show its status.
const PROFILE_SQL = ` const PROFILE_SQL = `
SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant, SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant,
m.adhesion, m.email, m.tel, m.site, m.adresse, m.presentation, m.valide, m.adhesion, m.email, m.tel, m.site, m.adresse, m.presentation, m.valide,
m.logo_path, m.photo_path m.logo_path, m.photo_path,
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id ic.decision AS image_consent, ic.scopes AS image_consent_scopes,
ic.created_at AS image_consent_at
FROM members m
LEFT JOIN categories c ON c.id = m.categorie_id
LEFT JOIN LATERAL (
SELECT decision, scopes, created_at FROM image_consents
WHERE member_id = m.id ORDER BY created_at DESC LIMIT 1
) ic ON true
WHERE m.id = $1`; WHERE m.id = $1`;
memberRouter.get('/profile', async (req, res, next) => { memberRouter.get('/profile', async (req, res, next) => {
@@ -85,3 +96,30 @@ function imageRoute(column) {
memberRouter.post('/profile/logo', imageRoute('logo_path')); memberRouter.post('/profile/logo', imageRoute('logo_path'));
memberRouter.post('/profile/photo', imageRoute('photo_path')); memberRouter.post('/profile/photo', imageRoute('photo_path'));
// Records an electronic image-rights consent (or refusal) as a new row in
// the append-only trail — a simple electronic signature valid as proof
// under art. 7 RGPD. Captures IP and user-agent for the record.
memberRouter.post('/image-consent', validate(imageConsentSchema), async (req, res, next) => {
try {
const { decision, scopes, signatoryName, signaturePng } = req.data;
await query(
`INSERT INTO image_consents
(member_id, decision, scopes, signatory_name, signature_png, consent_version, ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
req.user.memberId,
decision,
(scopes || []).join(','),
signatoryName,
decision === 'accepted' ? signaturePng : null,
IMAGE_CONSENT_VERSION,
req.ip || '',
(req.headers['user-agent'] || '').slice(0, 400),
]
);
res.status(201).json({ ok: true });
} catch (err) {
next(err);
}
});
+8 -8
View File
@@ -113,7 +113,7 @@ publicRouter.get(
[req.params.id] [req.params.id]
), ),
query( query(
`SELECT nom, statut FROM inscriptions `SELECT nom, statut, image_consent FROM inscriptions
WHERE rencontre_id = $1 AND member_id = $2 ORDER BY created_at, id`, WHERE rencontre_id = $1 AND member_id = $2 ORDER BY created_at, id`,
[req.params.id, req.user.memberId] [req.params.id, req.user.memberId]
), ),
@@ -144,7 +144,7 @@ publicRouter.post(
client = await pool.connect(); client = await pool.connect();
const { id } = req.params; const { id } = req.params;
const participants = req.data.participants; const participants = req.data.participants;
const names = participants.map((name) => name.toLocaleLowerCase('fr-FR')); const names = participants.map((p) => p.nom.toLocaleLowerCase('fr-FR'));
if (new Set(names).size !== names.length) { if (new Set(names).size !== names.length) {
return res.status(400).json({ error: 'Chaque participant doit avoir un nom différent.' }); return res.status(400).json({ error: 'Chaque participant doit avoir un nom différent.' });
} }
@@ -200,20 +200,20 @@ publicRouter.post(
const keptIds = []; const keptIds = [];
const account = member.rows[0]; const account = member.rows[0];
for (const nom of participants) { for (const { nom, imageConsent } of participants) {
const key = nom.toLocaleLowerCase('fr-FR'); const key = nom.toLocaleLowerCase('fr-FR');
const row = available.get(key)?.shift(); const row = available.get(key)?.shift();
if (row) { if (row) {
keptIds.push(row.id); keptIds.push(row.id);
await client.query( await client.query(
'UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4 WHERE id=$5', 'UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4, image_consent=$5 WHERE id=$6',
[nom, account.nom, account.email, account.tel, row.id] [nom, account.nom, account.email, account.tel, imageConsent, row.id]
); );
} else { } else {
const inserted = await client.query( const inserted = await client.query(
`INSERT INTO inscriptions (rencontre_id, member_id, nom, entreprise, email, tel, statut) `INSERT INTO inscriptions (rencontre_id, member_id, nom, entreprise, email, tel, statut, image_consent)
VALUES ($1, $2, $3, $4, $5, $6, 'en_attente') RETURNING id`, VALUES ($1, $2, $3, $4, $5, $6, 'en_attente', $7) RETURNING id`,
[id, account.id, nom, account.nom, account.email, account.tel] [id, account.id, nom, account.nom, account.email, account.tel, imageConsent]
); );
keptIds.push(inserted.rows[0].id); keptIds.push(inserted.rows[0].id);
} }
+26 -1
View File
@@ -31,8 +31,15 @@ export const demandeSchema = z.object({
tel: trimmed(30, 6), tel: trimmed(30, 6),
}); });
// Each participant carries their own image-rights decision (an adult can't
// consent for another — art. 9 Code civil), and the member attests they
// informed each accompanying person and obtained their agreement.
export const inscriptionPublicSchema = z.object({ export const inscriptionPublicSchema = z.object({
participants: z.array(trimmed(120, 1)).min(1).max(100), participants: z
.array(z.object({ nom: trimmed(120, 1), imageConsent: z.boolean() }))
.min(1)
.max(100),
attestation: z.literal(true, { errorMap: () => ({ message: "l'attestation est requise" }) }),
}); });
export const memberProfileSchema = z.object({ export const memberProfileSchema = z.object({
@@ -51,6 +58,24 @@ export const adminMemberSchema = memberProfileSchema.extend({
valide: z.boolean().optional(), valide: z.boolean().optional(),
}); });
// Electronic image-rights consent submitted by the member. A drawn PNG
// signature and at least one publication scope are required to authorize;
// refusing needs neither. Signature kept small (canvas ~500x160) to stay
// well under the JSON body limit.
export const imageConsentSchema = z
.object({
decision: z.enum(['accepted', 'refused']),
scopes: z.array(z.enum(['site', 'social', 'print'])).max(3).optional().default([]),
signatoryName: trimmed(120, 1),
signaturePng: z.string().max(60000).optional().default(''),
})
.refine(
(d) =>
d.decision !== 'accepted' ||
(d.signaturePng.startsWith('data:image/png;base64,') && d.scopes.length > 0),
{ message: 'une signature et au moins un support sont requis pour autoriser', path: ['signaturePng'] }
);
export const rencontreSchema = z.object({ export const rencontreSchema = z.object({
titre: trimmed(200, 1), titre: trimmed(200, 1),
date_renc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'), date_renc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'),
+25
View File
@@ -30,6 +30,26 @@ ALTER TABLE members ADD COLUMN IF NOT EXISTS billing_type TEXT NOT NULL DEFAULT
ALTER TABLE members DROP CONSTRAINT IF EXISTS members_billing_type_check; ALTER TABLE members DROP CONSTRAINT IF EXISTS members_billing_type_check;
ALTER TABLE members ADD CONSTRAINT members_billing_type_check CHECK (billing_type IN ('sluc_partner', 'non_partner')); ALTER TABLE members ADD CONSTRAINT members_billing_type_check CHECK (billing_type IN ('sluc_partner', 'non_partner'));
-- Image-rights consent (droit à l'image) collected electronically from the
-- member. Append-only audit trail: the current consent is the latest row
-- for a member, and withdrawing/changing adds a new row. Each row is a
-- simple electronic signature (art. 7 RGPD proof of consent): who, what
-- (decision + scopes + text version), when (created_at), plus a drawn
-- signature, IP and user-agent.
CREATE TABLE IF NOT EXISTS image_consents (
id BIGSERIAL PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
decision TEXT NOT NULL CHECK (decision IN ('accepted', 'refused')),
scopes TEXT NOT NULL DEFAULT '',
signatory_name TEXT NOT NULL CHECK (char_length(signatory_name) BETWEEN 1 AND 120),
signature_png TEXT,
consent_version TEXT NOT NULL DEFAULT '',
ip TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_image_consents_member ON image_consents(member_id, created_at DESC);
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254), email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254),
@@ -86,6 +106,11 @@ CREATE TABLE IF NOT EXISTS inscriptions (
CREATE INDEX IF NOT EXISTS idx_inscriptions_rencontre ON inscriptions(rencontre_id); CREATE INDEX IF NOT EXISTS idx_inscriptions_rencontre ON inscriptions(rencontre_id);
ALTER TABLE inscriptions ADD COLUMN IF NOT EXISTS member_id INTEGER REFERENCES members(id) ON DELETE SET NULL; ALTER TABLE inscriptions ADD COLUMN IF NOT EXISTS member_id INTEGER REFERENCES members(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_inscriptions_member_rencontre ON inscriptions(member_id, rencontre_id); CREATE INDEX IF NOT EXISTS idx_inscriptions_member_rencontre ON inscriptions(member_id, rencontre_id);
-- Per-participant image-rights declaration collected at event registration:
-- an adult cannot consent for another adult (art. 9 Code civil), so each
-- accompanying person's own decision is recorded, the member attesting they
-- informed the person and obtained their agreement. NULL = not answered.
ALTER TABLE inscriptions ADD COLUMN IF NOT EXISTS image_consent BOOLEAN;
-- Attach legacy registrations when their email identifies a member account. -- Attach legacy registrations when their email identifies a member account.
UPDATE inscriptions i SET member_id = m.id UPDATE inscriptions i SET member_id = m.id
FROM members m FROM members m
+79
View File
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { api } from '../lib/api.js';
import SignaturePad from './SignaturePad.jsx';
// Blanket authorization: one signature covers any photo of the member, on
// all of the association's media. Recorded server-side with the full scope
// list so the record stays explicit.
const ALL_SCOPES = ['site', 'social', 'print'];
const CONSENT_TEXT =
"J'autorise l'association à photographier, reproduire et diffuser mon image — toute photographie de moi " +
"prise lors des rencontres et manifestations du club, ainsi que ma photographie de profil — sur l'ensemble " +
"de ses supports de communication (site internet, réseaux sociaux, supports imprimés). Cette autorisation " +
"générale est consentie à titre gratuit, pour une durée de 5 ans, pour une diffusion en France et à " +
"l'étranger. Les images ne seront ni cédées à des tiers ni utilisées à des fins commerciales. Je peux " +
"retirer mon consentement à tout moment depuis mon espace membre.";
// Interactive image-rights consent form. Records a single blanket
// electronic authorization (or a refusal), then calls onDone.
export function ImageConsentForm({ dirigeant, onDone, compact = false }) {
const [name, setName] = useState(dirigeant || '');
const [signature, setSignature] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const submit = async (decision) => {
setError('');
if (!name.trim()) return setError('Indiquez votre nom et prénom.');
if (decision === 'accepted' && !signature) return setError('Signez dans le cadre pour autoriser.');
setBusy(true);
try {
await api.post('/api/member/image-consent', {
decision,
scopes: decision === 'accepted' ? ALL_SCOPES : [],
signatoryName: name.trim(),
signaturePng: decision === 'accepted' ? signature : '',
});
onDone?.(decision);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<p style={{ fontSize: 13.5, lineHeight: 1.65, color: 'var(--gray)' }}>{CONSENT_TEXT}</p>
<label className="field">Nom et prénom du signataire
<input value={name} onChange={(e) => setName(e.target.value)} maxLength={120} placeholder="Votre nom et prénom" />
</label>
<div>
<div style={{ fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--gray-mid)', marginBottom: 8 }}>
Signature
</div>
<SignaturePad onChange={setSignature} />
</div>
{error && <p className="error-text">{error}</p>}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn btn-red btn-sm" style={{ fontSize: 14.5, padding: '13px 26px' }} disabled={busy} onClick={() => submit('accepted')}>
{busy ? 'Enregistrement…' : "J'autorise et je signe"}
</button>
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14, padding: '13px 22px' }} disabled={busy} onClick={() => submit('refused')}>
Je n'autorise pas
</button>
</div>
{!compact && (
<p style={{ fontSize: 11.5, color: 'var(--gray-light)', lineHeight: 1.55 }}>
Votre choix, la date et votre signature sont enregistrés comme preuve de consentement (art. 7 RGPD),
et modifiables à tout moment. Refuser n'a aucune conséquence sur votre adhésion.
</p>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
// Self-contained signature pad: draw with mouse or finger, clear, and get
// the drawing as a PNG data URL via onChange. Kept at a small fixed size so
// the exported PNG stays well under the request body limit.
const W = 500;
const H = 160;
export default function SignaturePad({ onChange }) {
const canvasRef = useRef(null);
const drawing = useRef(false);
const last = useRef(null);
const [empty, setEmpty] = useState(true);
useEffect(() => {
const ctx = canvasRef.current.getContext('2d');
ctx.lineWidth = 2.2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#1B1B1B';
}, []);
const pos = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const p = e.touches ? e.touches[0] : e;
return { x: (p.clientX - rect.left) * (W / rect.width), y: (p.clientY - rect.top) * (H / rect.height) };
};
const start = (e) => {
e.preventDefault();
drawing.current = true;
last.current = pos(e);
};
const move = (e) => {
if (!drawing.current) return;
e.preventDefault();
const ctx = canvasRef.current.getContext('2d');
const p = pos(e);
ctx.beginPath();
ctx.moveTo(last.current.x, last.current.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
last.current = p;
if (empty) setEmpty(false);
};
const end = () => {
if (!drawing.current) return;
drawing.current = false;
onChange?.(empty ? '' : canvasRef.current.toDataURL('image/png'));
};
const clear = () => {
canvasRef.current.getContext('2d').clearRect(0, 0, W, H);
setEmpty(true);
onChange?.('');
};
return (
<div>
<canvas
ref={canvasRef}
width={W}
height={H}
style={{ width: '100%', maxWidth: W, height: 'auto', aspectRatio: `${W} / ${H}`, border: '1px solid var(--input-border)', borderRadius: 6, background: '#fff', touchAction: 'none', cursor: 'crosshair', display: 'block' }}
onMouseDown={start}
onMouseMove={move}
onMouseUp={end}
onMouseLeave={end}
onTouchStart={start}
onTouchMove={move}
onTouchEnd={end}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 6 }}>
<span style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Signez ci-dessus avec la souris ou le doigt.</span>
<button type="button" className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={clear}>Effacer</button>
</div>
</div>
);
}
+82 -7
View File
@@ -97,6 +97,56 @@ function MemberFormModal({ member, categories, onClose, onSaved }) {
); );
} }
const SCOPE_LABELS = { site: 'Site internet', social: 'Réseaux sociaux', print: 'Supports imprimés' };
// Shows the member's recorded image-rights consent: decision, scopes, the
// drawn signature and the audit metadata (date, IP) — the proof of consent.
function ConsentRecordModal({ member, onClose }) {
const [consent, setConsent] = useState(undefined); // undefined = loading
useEffect(() => {
api.get(`/api/admin/members/${member.id}/image-consent`).then((d) => setConsent(d.consent)).catch(() => setConsent(null));
}, [member.id]);
return (
<Modal onClose={onClose} maxWidth={560} header={{ kicker: "Droit à l'image", title: member.nom }}>
<div style={{ padding: '26px 30px' }}>
{consent === undefined && <p style={{ color: 'var(--gray-light)' }}>Chargement…</p>}
{consent === null && <p style={{ color: 'var(--gray-light)' }}>Aucun consentement enregistré pour ce membre.</p>}
{consent && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<span className={`badge ${consent.decision === 'accepted' ? 'badge-green' : 'badge-red'}`}>
{consent.decision === 'accepted' ? 'Publication autorisée' : 'Publication refusée'}
</span>
</div>
{consent.decision === 'accepted' && (
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--gray-mid)', marginBottom: 6 }}>Supports autorisés</div>
<div style={{ fontSize: 13.5, color: 'var(--gray)' }}>
{(consent.scopes || '').split(',').filter(Boolean).map((s) => SCOPE_LABELS[s] || s).join(', ') || '—'}
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 13 }}>
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Signataire</div>{consent.signatory_name}</div>
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Date</div>{new Date(consent.created_at).toLocaleString('fr-FR')}</div>
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Adresse IP</div>{consent.ip || '—'}</div>
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Version</div>{consent.consent_version || '—'}</div>
</div>
{consent.signature_png && (
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--gray-mid)', marginBottom: 6 }}>Signature</div>
<img src={consent.signature_png} alt="Signature" style={{ maxWidth: '100%', border: '1px solid var(--border)', borderRadius: 6, background: '#fff' }} />
</div>
)}
</div>
)}
</div>
</Modal>
);
}
export function MembersTab() { export function MembersTab() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user.role === 'admin'; const isAdmin = user.role === 'admin';
@@ -104,6 +154,7 @@ export function MembersTab() {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [modal, setModal] = useState(null); // null | 'new' | member const [modal, setModal] = useState(null); // null | 'new' | member
const [credentials, setCredentials] = useState(null); // { email, tempPassword } const [credentials, setCredentials] = useState(null); // { email, tempPassword }
const [consentView, setConsentView] = useState(null); // member whose consent to view
const [accessError, setAccessError] = useState(''); const [accessError, setAccessError] = useState('');
const season = seasonLabel(); const season = seasonLabel();
@@ -162,7 +213,7 @@ export function MembersTab() {
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th>Entreprise</th><th>Secteur</th><th>Dirigeant</th><th>Saison</th><th>Accès</th><th></th> <th>Entreprise</th><th>Secteur</th><th>Dirigeant</th><th>Saison</th><th>Image</th><th>Accès</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -176,6 +227,20 @@ export function MembersTab() {
{m.valide ? `Validé ${season}` : 'Non validé'} {m.valide ? `Validé ${season}` : 'Non validé'}
</span> </span>
</td> </td>
<td>
{m.image_consent ? (
<button
className={`badge ${m.image_consent === 'accepted' ? 'badge-green' : 'badge-red'}`}
style={{ border: 'none', cursor: 'pointer' }}
title="Voir le consentement signé"
onClick={() => setConsentView(m)}
>
{m.image_consent === 'accepted' ? 'Autorisé' : 'Refusé'}
</button>
) : (
<span className="badge badge-amber">En attente</span>
)}
</td>
<td> <td>
<AccessCell <AccessCell
hasEmail={!!m.email} hasEmail={!!m.email}
@@ -221,6 +286,7 @@ export function MembersTab() {
onClose={() => setCredentials(null)} onClose={() => setCredentials(null)}
/> />
)} )}
{consentView && <ConsentRecordModal member={consentView} onClose={() => setConsentView(null)} />}
</div> </div>
); );
} }
@@ -497,9 +563,10 @@ function ParticipantsModal({ renc, associationName, onClose, onEdit, onCancel, r
const exportExcel = () => { const exportExcel = () => {
let html = '<html xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="utf-8"></head><body><table border="1"><thead>'; let html = '<html xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="utf-8"></head><body><table border="1"><thead>';
html += '<tr><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Rencontre</th><th>Statut</th></tr></thead><tbody>'; const imgLabel = (v) => (v === true ? 'Autorisée' : v === false ? 'Refusée' : '');
html += '<tr><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Rencontre</th><th>Droit image</th><th>Statut</th></tr></thead><tbody>';
rows.forEach((p) => { rows.forEach((p) => {
html += `<tr><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(renc.titre)}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`; html += `<tr><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(renc.titre)}</td><td>${escHtml(imgLabel(p.image_consent))}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`;
}); });
html += '</tbody></table></body></html>'; html += '</tbody></table></body></html>';
const blob = new Blob(['' + html], { type: 'application/vnd.ms-excel' }); const blob = new Blob(['' + html], { type: 'application/vnd.ms-excel' });
@@ -516,8 +583,9 @@ function ParticipantsModal({ renc, associationName, onClose, onEdit, onCancel, r
const print = () => { const print = () => {
let body = ''; let body = '';
const imgLabel = (v) => (v === true ? 'Autorisée' : v === false ? 'Refusée' : '—');
rows.forEach((p, i) => { rows.forEach((p, i) => {
body += `<tr><td>${i + 1}</td><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`; body += `<tr><td>${i + 1}</td><td>${escHtml(p.nom)}</td><td>${escHtml(p.entreprise)}</td><td>${escHtml(p.email)}</td><td>${escHtml(p.tel)}</td><td>${escHtml(imgLabel(p.image_consent))}</td><td>${escHtml(statutLabel(p.statut))}</td></tr>`;
}); });
const doc = `<!doctype html><html><head><meta charset="utf-8"><title>Inscrits — ${escHtml(renc.titre)}</title>` const doc = `<!doctype html><html><head><meta charset="utf-8"><title>Inscrits — ${escHtml(renc.titre)}</title>`
+ '<style>body{font-family:Arial,Helvetica,sans-serif;color:#1B1B1B;padding:32px}h1{font-size:22px;margin:0 0 4px}.meta{color:#666;font-size:13px;margin-bottom:20px}' + '<style>body{font-family:Arial,Helvetica,sans-serif;color:#1B1B1B;padding:32px}h1{font-size:22px;margin:0 0 4px}.meta{color:#666;font-size:13px;margin-bottom:20px}'
@@ -525,7 +593,7 @@ function ParticipantsModal({ renc, associationName, onClose, onEdit, onCancel, r
+ '.brand{color:#C1272D;font-weight:700;font-size:12px;letter-spacing:.1em;text-transform:uppercase;margin-bottom:16px}</style></head><body>' + '.brand{color:#C1272D;font-weight:700;font-size:12px;letter-spacing:.1em;text-transform:uppercase;margin-bottom:16px}</style></head><body>'
+ `<div class="brand">${escHtml(associationName)}</div>` + `<div class="brand">${escHtml(associationName)}</div>`
+ `<h1>${escHtml(renc.titre)}</h1><div class="meta">${escHtml(meta)} — ${rows.length} inscrits</div>` + `<h1>${escHtml(renc.titre)}</h1><div class="meta">${escHtml(meta)} — ${rows.length} inscrits</div>`
+ '<table><thead><tr><th>#</th><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Statut</th></tr></thead><tbody>' + '<table><thead><tr><th>#</th><th>Participant</th><th>Entreprise</th><th>Email</th><th>Téléphone</th><th>Droit image</th><th>Statut</th></tr></thead><tbody>'
+ body + '</tbody></table></body></html>'; + body + '</tbody></table></body></html>';
const w = window.open('', '_blank'); const w = window.open('', '_blank');
if (!w) return; if (!w) return;
@@ -548,14 +616,21 @@ function ParticipantsModal({ renc, associationName, onClose, onEdit, onCancel, r
<div style={{ padding: '6px 0 12px' }}> <div style={{ padding: '6px 0 12px' }}>
<table className="table"> <table className="table">
<thead> <thead>
<tr><th>Participant</th><th>Entreprise</th><th>Email</th><th>Statut</th><th></th></tr> <tr><th>Participant</th><th>Entreprise</th><th>Image</th><th>Statut</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((p) => ( {rows.map((p) => (
<tr key={p.id}> <tr key={p.id}>
<td>{p.nom}</td> <td>{p.nom}</td>
<td>{p.entreprise}</td> <td>{p.entreprise}</td>
<td>{p.email}</td> <td>
<span
className={`badge ${p.image_consent === true ? 'badge-green' : p.image_consent === false ? 'badge-red' : 'badge-amber'}`}
title="Autorisation de publication de l'image du participant"
>
{p.image_consent === true ? 'Autorisée' : p.image_consent === false ? 'Refusée' : '—'}
</span>
</td>
<td><span className={`badge ${p.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(p.statut)}</span></td> <td><span className={`badge ${p.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(p.statut)}</span></td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}> <td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn-link" style={{ fontSize: 12.5, marginRight: 12 }} onClick={() => onEdit(p)}>Modifier</button> <button className="btn-link" style={{ fontSize: 12.5, marginRight: 12 }} onClick={() => onEdit(p)}>Modifier</button>
+9 -6
View File
@@ -39,8 +39,9 @@ export default function Confidentialite() {
<div style={row}> <div style={row}>
<span style={cellTitle}>Inscription à une rencontre</span> <span style={cellTitle}>Inscription à une rencontre</span>
<span> <span>
Noms et prénoms des participants, rattachés au compte membre qui les inscrit. Noms et prénoms des participants (membre et accompagnants), rattachés au compte membre, et
<br /><em>Finalité :</em> gérer les inscriptions et l'accueil des participants. <em>Base légale :</em> exécution de l'adhésion. l'autorisation de droit à l'image recueillie pour chacun.
<br /><em>Finalité :</em> gérer les inscriptions et l'accueil des participants. <em>Base légale :</em> exécution de l'adhésion ; l'autorisation d'image de chaque personne repose sur son consentement (art. 9 du Code civil), le membre attestant l'avoir recueilli.
</span> </span>
</div> </div>
<div style={row}> <div style={row}>
@@ -118,10 +119,12 @@ export default function Confidentialite() {
<LegalSection title="Droit à l'image"> <LegalSection title="Droit à l'image">
<p> <p>
Des photographies sont prises lors des rencontres du club et peuvent être publiées sur ce site Des photographies sont prises lors des rencontres du club et peuvent être publiées sur ses supports
(section « Rencontres passées »). Elles ne sont publiées qu'avec l'accord des personnes (site, réseaux sociaux, documents imprimés). Conformément à l'article 9 du Code civil, chaque
identifiables. Si vous apparaissez sur une photographie et souhaitez son retrait, écrivez à{' '} personne identifiable donne sa propre autorisation : les membres via leur espace personnel, les
<LegalValue value={email} label="email de contact" /> : elle sera retirée dans les meilleurs délais. accompagnants au moment de leur inscription à l'événement. Pour un mineur, l'autorisation est donnée
par son représentant légal. Si vous apparaissez sur une photographie et souhaitez son retrait, écrivez
à <LegalValue value={email} label="email de contact" /> : elle sera retirée dans les meilleurs délais.
</p> </p>
</LegalSection> </LegalSection>
+78
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useAuth } from '../lib/AuthContext.jsx'; import { useAuth } from '../lib/AuthContext.jsx';
import { api, seasonLabel } from '../lib/api.js'; import { api, seasonLabel } from '../lib/api.js';
import ImageSlot from '../components/ImageSlot.jsx'; import ImageSlot from '../components/ImageSlot.jsx';
import { ImageConsentForm } from '../components/ImageConsent.jsx';
import { AdminShell } from './Admin.jsx'; import { AdminShell } from './Admin.jsx';
function LoginSection({ title = 'Gérez votre présence au Club', kicker = 'Espace membre' }) { function LoginSection({ title = 'Gérez votre présence au Club', kicker = 'Espace membre' }) {
@@ -153,6 +154,69 @@ function ForcedPasswordChange() {
); );
} }
// Image-rights authorization prompt shown at a member's first access. It
// is NOT blocking: the member can sign the general authorization, refuse,
// or continue and decide later. Publication is never gated on this — it is
// the association's recorded authorization, not a technical lock.
function ImageConsentGate({ dirigeant, onDone, onSkip }) {
return (
<section style={{ minHeight: 'calc(100vh - 76px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 24px', background: 'var(--bg)' }}>
<div className="card" style={{ width: '100%', maxWidth: 620, borderRadius: 10, padding: '34px 36px' }}>
<div className="kicker" style={{ marginBottom: 12 }}>Droit à l'image</div>
<h1 className="serif" style={{ fontWeight: 500, fontSize: 27, lineHeight: 1.15, marginBottom: 8 }}>
Autorisation de publier votre image
</h1>
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.6, marginBottom: 22 }}>
En signant, vous autorisez le club à publier toute photo de vous sur ses supports. C'est une
autorisation générale, valable pour l'ensemble de vos photos — vous n'aurez plus à valider chaque
publication. Modifiable à tout moment depuis votre espace.
</p>
<ImageConsentForm dirigeant={dirigeant} onDone={() => onDone?.()} />
<button type="button" onClick={onSkip} className="btn-link-gray" style={{ marginTop: 18 }}>
Plus tard — accéder à mon espace
</button>
</div>
</section>
);
}
// In-portal card: shows the current authorization status and lets the
// member sign, update or withdraw it.
function ConsentCard({ member, dirigeant, onChanged }) {
const [editing, setEditing] = useState(false);
const decision = member.image_consent; // 'accepted' | 'refused' | null
const at = member.image_consent_at ? new Date(member.image_consent_at).toLocaleDateString('fr-FR') : null;
const accepted = decision === 'accepted';
return (
<div className="card" style={{ borderRadius: 8, padding: 30, marginTop: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14, flexWrap: 'wrap' }}>
<h2 className="serif" style={{ fontSize: 22, fontWeight: 600 }}>Droit à l'image</h2>
<span className={`badge ${accepted ? 'badge-green' : decision === 'refused' ? 'badge-red' : 'badge-amber'}`}>
{accepted ? 'Autorisation signée' : decision === 'refused' ? 'Refusé' : 'Non signée'}
</span>
</div>
<p style={{ fontSize: 13.5, color: 'var(--gray-light)', lineHeight: 1.6, margin: '8px 0 0' }}>
{accepted
? `Vous avez autorisé la publication de toute photo de vous${at ? ` le ${at}` : ''}. Aucune validation supplémentaire n'est nécessaire.`
: decision === 'refused'
? `Vous avez refusé la publication de votre image${at ? ` le ${at}` : ''}. Pour toute question, contactez le club.`
: "Vous n'avez pas encore signé l'autorisation de droit à l'image."}
</p>
{!editing ? (
<button className="btn btn-outline-soft btn-sm" style={{ marginTop: 16, fontSize: 14, padding: '11px 20px' }} onClick={() => setEditing(true)}>
{decision ? 'Modifier mon choix' : "Signer l'autorisation"}
</button>
) : (
<div style={{ marginTop: 18 }}>
<ImageConsentForm dirigeant={dirigeant} compact onDone={() => { setEditing(false); onChanged?.(); }} />
<button type="button" className="btn-link-gray" style={{ marginTop: 10 }} onClick={() => setEditing(false)}>Annuler</button>
</div>
)}
</div>
);
}
function Portal() { function Portal() {
const { logout } = useAuth(); const { logout } = useAuth();
const [member, setMember] = useState(null); const [member, setMember] = useState(null);
@@ -162,6 +226,9 @@ function Portal() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const season = seasonLabel(); const season = seasonLabel();
const loadProfile = () =>
api.get('/api/member/profile').then((p) => { setMember(p.member); setForm((f) => f ? { ...f, ...p.member } : p.member); });
useEffect(() => { useEffect(() => {
Promise.all([api.get('/api/member/profile'), api.get('/api/public/bootstrap')]) Promise.all([api.get('/api/member/profile'), api.get('/api/public/bootstrap')])
.then(([p, b]) => { .then(([p, b]) => {
@@ -180,6 +247,11 @@ function Portal() {
); );
} }
// First access: require an explicit image-rights decision before the portal.
if (member.image_consent == null) {
return <ImageConsentGate dirigeant={member.dirigeant} onDone={loadProfile} />;
}
const onChange = (e) => { const onChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
setForm({ ...form, [name]: name === 'categorie_id' ? (value ? Number(value) : null) : value }); setForm({ ...form, [name]: name === 'categorie_id' ? (value ? Number(value) : null) : value });
@@ -251,6 +323,11 @@ function Portal() {
<div style={{ width: 96, height: 96 }}> <div style={{ width: 96, height: 96 }}>
<ImageSlot circle endpoint="/api/member/profile/photo" value={form.photo_path} placeholder="Déposez votre photo" onUploaded={(path) => { setForm((f) => ({ ...f, photo_path: path })); setMember((m) => ({ ...m, photo_path: path })); }} /> <ImageSlot circle endpoint="/api/member/profile/photo" value={form.photo_path} placeholder="Déposez votre photo" onUploaded={(path) => { setForm((f) => ({ ...f, photo_path: path })); setMember((m) => ({ ...m, photo_path: path })); }} />
</div> </div>
{form.photo_path && member.image_consent !== 'accepted' && (
<div style={{ fontSize: 10.5, color: 'var(--red)', marginTop: 6, lineHeight: 1.4, maxWidth: 96 }}>
Non publiée sans autorisation d'image
</div>
)}
</div> </div>
</div> </div>
@@ -298,6 +375,7 @@ function Portal() {
</div> </div>
</form> </form>
</div> </div>
<ConsentCard member={member} dirigeant={form.dirigeant} onChanged={loadProfile} />
<PasswordCard /> <PasswordCard />
</div> </div>
+43 -20
View File
@@ -11,7 +11,10 @@ import { associationSettings } from '../lib/siteSettings.js';
export function InscriptionModal({ rencontre, onClose, onDone }) { export function InscriptionModal({ rencontre, onClose, onDone }) {
const { user, login } = useAuth(); const { user, login } = useAuth();
const [context, setContext] = useState(null); const [context, setContext] = useState(null);
const [participants, setParticipants] = useState(['']); // Each participant: { nom, imageConsent } — image consent is recorded per
// person (an adult can't consent for another, art. 9 Code civil).
const [participants, setParticipants] = useState([{ nom: '', imageConsent: true }]);
const [attestation, setAttestation] = useState(false);
const [credentials, setCredentials] = useState({ email: '', password: '' }); const [credentials, setCredentials] = useState({ email: '', password: '' });
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [wasRegistered, setWasRegistered] = useState(false); const [wasRegistered, setWasRegistered] = useState(false);
@@ -26,7 +29,13 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
.then((d) => { .then((d) => {
setContext(d); setContext(d);
setWasRegistered(d.participants.length > 0); setWasRegistered(d.participants.length > 0);
setParticipants(d.participants.length ? d.participants.map((p) => p.nom) : [d.member.dirigeant || '']); setParticipants(
d.participants.length
? d.participants.map((p) => ({ nom: p.nom, imageConsent: p.image_consent !== false }))
: [{ nom: d.member.dirigeant || '', imageConsent: true }]
);
// A returning registration already carried the attestation.
setAttestation(d.participants.length > 0);
}) })
.catch((err) => setError(err.message)); .catch((err) => setError(err.message));
}, [user, rencontre.id]); }, [user, rencontre.id]);
@@ -45,7 +54,10 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
}; };
const changeParticipant = (index, value) => { const changeParticipant = (index, value) => {
setParticipants((current) => current.map((name, i) => (i === index ? value : name))); setParticipants((current) => current.map((p, i) => (i === index ? { ...p, nom: value } : p)));
};
const toggleParticipantImage = (index) => {
setParticipants((current) => current.map((p, i) => (i === index ? { ...p, imageConsent: !p.imageConsent } : p)));
}; };
const maxPerAccount = context?.rencontre.participants_par_compte || rencontre.participants_par_compte || 1; const maxPerAccount = context?.rencontre.participants_par_compte || rencontre.participants_par_compte || 1;
@@ -53,9 +65,13 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
const submit = async (e) => { const submit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (!attestation) {
setError("Merci de confirmer l'attestation sur le droit à l'image.");
return;
}
setBusy(true); setBusy(true);
try { try {
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants }); await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants, attestation });
setDone(true); setDone(true);
onDone?.(); onDone?.();
} catch (err) { } catch (err) {
@@ -133,33 +149,40 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
<div style={{ background: 'var(--beige)', padding: '12px 14px', borderRadius: 4, fontSize: 13, color: 'var(--gray)', marginBottom: 18 }}> <div style={{ background: 'var(--beige)', padding: '12px 14px', borderRadius: 4, fontSize: 13, color: 'var(--gray)', marginBottom: 18 }}>
Compte : <strong>{context.member.nom}</strong> · maximum {maxPerAccount} participant{maxPerAccount > 1 ? 's' : ''} Compte : <strong>{context.member.nom}</strong> · maximum {maxPerAccount} participant{maxPerAccount > 1 ? 's' : ''}
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{participants.map((name, index) => ( {participants.map((p, index) => (
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}> <div key={index} style={{ border: '1px solid var(--border-soft)', borderRadius: 6, padding: '12px 14px' }}>
<label className="field-plain" style={{ flex: 1 }}>Participant {index + 1} — nom & prénom <div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<input value={name} onChange={(e) => changeParticipant(index, e.target.value)} required maxLength={120} /> <label className="field-plain" style={{ flex: 1 }}>Participant {index + 1} — nom & prénom
<input value={p.nom} onChange={(e) => changeParticipant(index, e.target.value)} required maxLength={120} />
</label>
{participants.length > 1 && (
<button type="button" className="btn btn-outline-soft btn-sm" style={{ padding: '11px 13px' }} onClick={() => setParticipants((current) => current.filter((_, i) => i !== index))} aria-label={`Retirer le participant ${index + 1}`}>×</button>
)}
</div>
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginTop: 10, fontSize: 12.5, color: 'var(--gray)', cursor: 'pointer' }}>
<input type="checkbox" checked={p.imageConsent} onChange={() => toggleParticipantImage(index)} style={{ marginTop: 2 }} />
<span>Cette personne autorise la publication de son image (photos de l'événement) sur les supports du club.</span>
</label> </label>
{participants.length > 1 && (
<button type="button" className="btn btn-outline-soft btn-sm" style={{ padding: '11px 13px' }} onClick={() => setParticipants((current) => current.filter((_, i) => i !== index))} aria-label={`Retirer le participant ${index + 1}`}>×</button>
)}
</div> </div>
))} ))}
</div> </div>
{participants.length < maxPerAccount && ( {participants.length < maxPerAccount && (
<button type="button" className="btn-link" style={{ marginTop: 14 }} onClick={() => setParticipants((current) => [...current, ''])}> <button type="button" className="btn-link" style={{ marginTop: 14 }} onClick={() => setParticipants((current) => [...current, { nom: '', imageConsent: true }])}>
+ Ajouter un participant + Ajouter un participant
</button> </button>
)} )}
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginTop: 16, fontSize: 12, color: 'var(--gray)', cursor: 'pointer', lineHeight: 1.5 }}>
<input type="checkbox" checked={attestation} onChange={(e) => setAttestation(e.target.checked)} style={{ marginTop: 2 }} />
<span>J'atteste avoir informé chaque personne inscrite que des photos peuvent être prises et publiées, et avoir recueilli son accord (ou son refus) ci-dessus. Pour un mineur, l'accord est donné par son représentant légal.</span>
</label>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>} {error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 22, padding: 14 }} disabled={busy}> <button type="submit" className="btn btn-red" style={{ width: '100%', marginTop: 18, padding: 14 }} disabled={busy}>
{busy ? 'Enregistrement…' : `Enregistrer ${participants.length} participant${participants.length > 1 ? 's' : ''}`} {busy ? 'Enregistrement…' : `Enregistrer ${participants.length} participant${participants.length > 1 ? 's' : ''}`}
</button> </button>
<p style={{ fontSize: 12, color: 'var(--gray-light)', textAlign: 'center', marginTop: 12, lineHeight: 1.5 }}> <p style={{ fontSize: 11, color: 'var(--gray-light)', textAlign: 'center', marginTop: 10, lineHeight: 1.5 }}>
Vous pourrez rouvrir ce formulaire pour modifier les noms enregistrés. Vous pourrez rouvrir ce formulaire pour modifier les noms et les choix.{' '}
</p> <Link to="/confidentialite" style={{ color: 'var(--red)' }}>Politique de confidentialité</Link>.
<p style={{ fontSize: 11, color: 'var(--gray-light)', textAlign: 'center', marginTop: 8, lineHeight: 1.5 }}>
Les noms recueillis servent uniquement à gérer les inscriptions à cette rencontre —{' '}
<Link to="/confidentialite" style={{ color: 'var(--red)' }}>politique de confidentialité</Link>.
</p> </p>
</form> </form>
)} )}