From b34e361cbeaf2ae4da4cfebcde6e9b232d88ee03 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 20:59:26 +0000 Subject: [PATCH] =?UTF-8?q?Droit=20=C3=A0=20l'image=20par=20signature=20?= =?UTF-8?q?=C3=A9lectronique=20(membre=20+=20accompagnants)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o --- server/src/imageConsent.js | 21 ++++++ server/src/routes/admin.js | 26 +++++++- server/src/routes/member.js | 44 ++++++++++++- server/src/routes/public.js | 16 ++--- server/src/schemas.js | 27 +++++++- server/src/sql/schema.sql | 25 ++++++++ web/src/components/ImageConsent.jsx | 79 +++++++++++++++++++++++ web/src/components/SignaturePad.jsx | 81 +++++++++++++++++++++++ web/src/components/admin/AdminTabs.jsx | 89 ++++++++++++++++++++++++-- web/src/pages/Confidentialite.jsx | 15 +++-- web/src/pages/Espace.jsx | 78 ++++++++++++++++++++++ web/src/pages/Home.jsx | 63 ++++++++++++------ 12 files changed, 516 insertions(+), 48 deletions(-) create mode 100644 server/src/imageConsent.js create mode 100644 web/src/components/ImageConsent.jsx create mode 100644 web/src/components/SignaturePad.jsx diff --git a/server/src/imageConsent.js b/server/src/imageConsent.js new file mode 100644 index 0000000..161035c --- /dev/null +++ b/server/src/imageConsent.js @@ -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."; diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 74540ec..2d912ef 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -40,10 +40,15 @@ const MEMBER_SQL = ` m.logo_path, m.photo_path, (u.id IS NOT NULL) AS has_login, 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 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 = ` 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 = ` 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`; 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) ---------- adminRouter.get('/rencontres', async (_req, res, next) => { try { diff --git a/server/src/routes/member.js b/server/src/routes/member.js index 895dcad..77cac58 100644 --- a/server/src/routes/member.js +++ b/server/src/routes/member.js @@ -2,18 +2,29 @@ import { Router } from 'express'; import { query } from '../db.js'; import { requireAuth } from '../middleware/auth.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 { IMAGE_CONSENT_VERSION } from '../imageConsent.js'; export const memberRouter = Router(); 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 = ` 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.logo_path, m.photo_path - FROM members m LEFT JOIN categories c ON c.id = m.categorie_id + m.logo_path, m.photo_path, + 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`; memberRouter.get('/profile', async (req, res, next) => { @@ -85,3 +96,30 @@ function imageRoute(column) { memberRouter.post('/profile/logo', imageRoute('logo_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); + } +}); diff --git a/server/src/routes/public.js b/server/src/routes/public.js index 08de27c..9ff8fde 100644 --- a/server/src/routes/public.js +++ b/server/src/routes/public.js @@ -113,7 +113,7 @@ publicRouter.get( [req.params.id] ), 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`, [req.params.id, req.user.memberId] ), @@ -144,7 +144,7 @@ publicRouter.post( client = await pool.connect(); const { id } = req.params; 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) { return res.status(400).json({ error: 'Chaque participant doit avoir un nom différent.' }); } @@ -200,20 +200,20 @@ publicRouter.post( const keptIds = []; const account = member.rows[0]; - for (const nom of participants) { + for (const { nom, imageConsent } of participants) { const key = nom.toLocaleLowerCase('fr-FR'); const row = available.get(key)?.shift(); if (row) { keptIds.push(row.id); await client.query( - 'UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4 WHERE id=$5', - [nom, account.nom, account.email, account.tel, row.id] + 'UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4, image_consent=$5 WHERE id=$6', + [nom, account.nom, account.email, account.tel, imageConsent, row.id] ); } else { const inserted = await client.query( - `INSERT INTO inscriptions (rencontre_id, member_id, nom, entreprise, email, tel, statut) - VALUES ($1, $2, $3, $4, $5, $6, 'en_attente') RETURNING id`, - [id, account.id, nom, account.nom, account.email, account.tel] + `INSERT INTO inscriptions (rencontre_id, member_id, nom, entreprise, email, tel, statut, image_consent) + VALUES ($1, $2, $3, $4, $5, $6, 'en_attente', $7) RETURNING id`, + [id, account.id, nom, account.nom, account.email, account.tel, imageConsent] ); keptIds.push(inserted.rows[0].id); } diff --git a/server/src/schemas.js b/server/src/schemas.js index 6812873..16675e3 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -31,8 +31,15 @@ export const demandeSchema = z.object({ 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({ - 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({ @@ -51,6 +58,24 @@ export const adminMemberSchema = memberProfileSchema.extend({ 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({ titre: trimmed(200, 1), date_renc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'), diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index 5b917df..d5df584 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -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 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 ( id SERIAL PRIMARY KEY, 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); 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); +-- 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. UPDATE inscriptions i SET member_id = m.id FROM members m diff --git a/web/src/components/ImageConsent.jsx b/web/src/components/ImageConsent.jsx new file mode 100644 index 0000000..2a6e18b --- /dev/null +++ b/web/src/components/ImageConsent.jsx @@ -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 ( +
+

{CONSENT_TEXT}

+ + + +
+
+ Signature +
+ +
+ + {error &&

{error}

} + +
+ + +
+ {!compact && ( +

+ 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. +

+ )} +
+ ); +} diff --git a/web/src/components/SignaturePad.jsx b/web/src/components/SignaturePad.jsx new file mode 100644 index 0000000..43fd846 --- /dev/null +++ b/web/src/components/SignaturePad.jsx @@ -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 ( +
+ +
+ Signez ci-dessus avec la souris ou le doigt. + +
+
+ ); +} diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx index c8ba0f0..2d8cc4d 100644 --- a/web/src/components/admin/AdminTabs.jsx +++ b/web/src/components/admin/AdminTabs.jsx @@ -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 ( + +
+ {consent === undefined &&

Chargement…

} + {consent === null &&

Aucun consentement enregistré pour ce membre.

} + {consent && ( +
+
+ + {consent.decision === 'accepted' ? 'Publication autorisée' : 'Publication refusée'} + +
+ {consent.decision === 'accepted' && ( +
+
Supports autorisés
+
+ {(consent.scopes || '').split(',').filter(Boolean).map((s) => SCOPE_LABELS[s] || s).join(', ') || '—'} +
+
+ )} +
+
Signataire
{consent.signatory_name}
+
Date
{new Date(consent.created_at).toLocaleString('fr-FR')}
+
Adresse IP
{consent.ip || '—'}
+
Version
{consent.consent_version || '—'}
+
+ {consent.signature_png && ( +
+
Signature
+ Signature +
+ )} +
+ )} +
+
+ ); +} + export function MembersTab() { const { user } = useAuth(); const isAdmin = user.role === 'admin'; @@ -104,6 +154,7 @@ export function MembersTab() { const [categories, setCategories] = useState([]); const [modal, setModal] = useState(null); // null | 'new' | member const [credentials, setCredentials] = useState(null); // { email, tempPassword } + const [consentView, setConsentView] = useState(null); // member whose consent to view const [accessError, setAccessError] = useState(''); const season = seasonLabel(); @@ -162,7 +213,7 @@ export function MembersTab() { - + @@ -176,6 +227,20 @@ export function MembersTab() { {m.valide ? `Validé ${season}` : 'Non validé'} + `; + body += ``; }); const doc = `Inscrits — ${escHtml(renc.titre)}` + '' + `
${escHtml(associationName)}
` + `

${escHtml(renc.titre)}

${escHtml(meta)} — ${rows.length} inscrits
` - + '
EntrepriseSecteurDirigeantSaisonAccèsEntrepriseSecteurDirigeantSaisonImageAccès
+ {m.image_consent ? ( + + ) : ( + En attente + )} + setCredentials(null)} /> )} + {consentView && setConsentView(null)} />} ); } @@ -497,9 +563,10 @@ function ParticipantsModal({ renc, associationName, onClose, onEdit, onCancel, r const exportExcel = () => { let html = ''; - html += ''; + const imgLabel = (v) => (v === true ? 'Autorisée' : v === false ? 'Refusée' : ''); + html += ''; rows.forEach((p) => { - html += ``; + html += ``; }); html += '
ParticipantEntrepriseEmailTéléphoneRencontreStatut
ParticipantEntrepriseEmailTéléphoneRencontreDroit imageStatut
${escHtml(p.nom)}${escHtml(p.entreprise)}${escHtml(p.email)}${escHtml(p.tel)}${escHtml(renc.titre)}${escHtml(statutLabel(p.statut))}
${escHtml(p.nom)}${escHtml(p.entreprise)}${escHtml(p.email)}${escHtml(p.tel)}${escHtml(renc.titre)}${escHtml(imgLabel(p.image_consent))}${escHtml(statutLabel(p.statut))}
'; const blob = new Blob(['' + html], { type: 'application/vnd.ms-excel' }); @@ -516,8 +583,9 @@ function ParticipantsModal({ renc, associationName, onClose, onEdit, onCancel, r const print = () => { let body = ''; + const imgLabel = (v) => (v === true ? 'Autorisée' : v === false ? 'Refusée' : '—'); rows.forEach((p, i) => { - body += `
${i + 1}${escHtml(p.nom)}${escHtml(p.entreprise)}${escHtml(p.email)}${escHtml(p.tel)}${escHtml(statutLabel(p.statut))}
${i + 1}${escHtml(p.nom)}${escHtml(p.entreprise)}${escHtml(p.email)}${escHtml(p.tel)}${escHtml(imgLabel(p.image_consent))}${escHtml(statutLabel(p.statut))}
' + + '
#ParticipantEntrepriseEmailTéléphoneStatut
' + body + '
#ParticipantEntrepriseEmailTéléphoneDroit imageStatut
'; const w = window.open('', '_blank'); if (!w) return; @@ -548,14 +616,21 @@ function ParticipantsModal({ renc, associationName, onClose, onEdit, onCancel, r
- + {rows.map((p) => ( - +
ParticipantEntrepriseEmailStatut
ParticipantEntrepriseImageStatut
{p.nom} {p.entreprise}{p.email} + + {p.image_consent === true ? 'Autorisée' : p.image_consent === false ? 'Refusée' : '—'} + + {statutLabel(p.statut)} diff --git a/web/src/pages/Confidentialite.jsx b/web/src/pages/Confidentialite.jsx index 611cd86..525bc9c 100644 --- a/web/src/pages/Confidentialite.jsx +++ b/web/src/pages/Confidentialite.jsx @@ -39,8 +39,9 @@ export default function Confidentialite() {
Inscription à une rencontre - Noms et prénoms des participants, rattachés au compte membre qui les inscrit. -
Finalité : gérer les inscriptions et l'accueil des participants. Base légale : exécution de l'adhésion. + Noms et prénoms des participants (membre et accompagnants), rattachés au compte membre, et + l'autorisation de droit à l'image recueillie pour chacun. +
Finalité : gérer les inscriptions et l'accueil des participants. Base légale : 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.
@@ -118,10 +119,12 @@ export default function Confidentialite() {

- Des photographies sont prises lors des rencontres du club et peuvent être publiées sur ce site - (section « Rencontres passées »). Elles ne sont publiées qu'avec l'accord des personnes - identifiables. Si vous apparaissez sur une photographie et souhaitez son retrait, écrivez à{' '} - : elle sera retirée dans les meilleurs délais. + Des photographies sont prises lors des rencontres du club et peuvent être publiées sur ses supports + (site, réseaux sociaux, documents imprimés). Conformément à l'article 9 du Code civil, chaque + personne identifiable donne sa propre autorisation : les membres via leur espace personnel, les + 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 + à : elle sera retirée dans les meilleurs délais.

diff --git a/web/src/pages/Espace.jsx b/web/src/pages/Espace.jsx index 0b0fd10..a508835 100644 --- a/web/src/pages/Espace.jsx +++ b/web/src/pages/Espace.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useAuth } from '../lib/AuthContext.jsx'; import { api, seasonLabel } from '../lib/api.js'; import ImageSlot from '../components/ImageSlot.jsx'; +import { ImageConsentForm } from '../components/ImageConsent.jsx'; import { AdminShell } from './Admin.jsx'; 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 ( +
+
+
Droit à l'image
+

+ Autorisation de publier votre image +

+

+ 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. +

+ onDone?.()} /> + +
+
+ ); +} + +// 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 ( +
+
+

Droit à l'image

+ + {accepted ? 'Autorisation signée' : decision === 'refused' ? 'Refusé' : 'Non signée'} + +
+

+ {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."} +

+ {!editing ? ( + + ) : ( +
+ { setEditing(false); onChanged?.(); }} /> + +
+ )} +
+ ); +} + function Portal() { const { logout } = useAuth(); const [member, setMember] = useState(null); @@ -162,6 +226,9 @@ function Portal() { const [error, setError] = useState(''); const season = seasonLabel(); + const loadProfile = () => + api.get('/api/member/profile').then((p) => { setMember(p.member); setForm((f) => f ? { ...f, ...p.member } : p.member); }); + useEffect(() => { Promise.all([api.get('/api/member/profile'), api.get('/api/public/bootstrap')]) .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 ; + } + const onChange = (e) => { const { name, value } = e.target; setForm({ ...form, [name]: name === 'categorie_id' ? (value ? Number(value) : null) : value }); @@ -251,6 +323,11 @@ function Portal() {
{ setForm((f) => ({ ...f, photo_path: path })); setMember((m) => ({ ...m, photo_path: path })); }} />
+ {form.photo_path && member.image_consent !== 'accepted' && ( +
+ Non publiée sans autorisation d'image +
+ )}
@@ -298,6 +375,7 @@ function Portal() { + diff --git a/web/src/pages/Home.jsx b/web/src/pages/Home.jsx index af1db9a..0f49d54 100644 --- a/web/src/pages/Home.jsx +++ b/web/src/pages/Home.jsx @@ -11,7 +11,10 @@ import { associationSettings } from '../lib/siteSettings.js'; export function InscriptionModal({ rencontre, onClose, onDone }) { const { user, login } = useAuth(); 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 [done, setDone] = useState(false); const [wasRegistered, setWasRegistered] = useState(false); @@ -26,7 +29,13 @@ export function InscriptionModal({ rencontre, onClose, onDone }) { .then((d) => { setContext(d); 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)); }, [user, rencontre.id]); @@ -45,7 +54,10 @@ export function InscriptionModal({ rencontre, onClose, onDone }) { }; 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; @@ -53,9 +65,13 @@ export function InscriptionModal({ rencontre, onClose, onDone }) { const submit = async (e) => { e.preventDefault(); setError(''); + if (!attestation) { + setError("Merci de confirmer l'attestation sur le droit à l'image."); + return; + } setBusy(true); try { - await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants }); + await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants, attestation }); setDone(true); onDone?.(); } catch (err) { @@ -133,33 +149,40 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
Compte : {context.member.nom} · maximum {maxPerAccount} participant{maxPerAccount > 1 ? 's' : ''}
-
- {participants.map((name, index) => ( -
-