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:
12 files changed
+516
-48
No files matched your search
@@ -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.";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+26
-1
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user