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,
|
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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user