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/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 (
+
+ {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() {