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

+23 -3
View File
@@ -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 {
+41 -3
View File
@@ -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);
}
});
+8 -8
View File
@@ -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);
}