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

+9 -6
View File
@@ -39,8 +39,9 @@ export default function Confidentialite() {
<div style={row}>
<span style={cellTitle}>Inscription à une rencontre</span>
<span>
Noms et prénoms des participants, rattachés au compte membre qui les inscrit.
<br /><em>Finalité :</em> gérer les inscriptions et l'accueil des participants. <em>Base légale :</em> 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.
<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>
</div>
<div style={row}>
@@ -118,10 +119,12 @@ export default function Confidentialite() {
<LegalSection title="Droit à l'image">
<p>
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 à{' '}
<LegalValue value={email} label="email de contact" /> : 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
à <LegalValue value={email} label="email de contact" /> : elle sera retirée dans les meilleurs délais.
</p>
</LegalSection>
+78
View File
@@ -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 (
<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() {
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 <ImageConsentGate dirigeant={member.dirigeant} onDone={loadProfile} />;
}
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() {
<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 })); }} />
</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>
@@ -298,6 +375,7 @@ function Portal() {
</div>
</form>
</div>
<ConsentCard member={member} dirigeant={form.dirigeant} onChanged={loadProfile} />
<PasswordCard />
</div>
+43 -20
View File
@@ -11,7 +11,10 @@ import { associationSettings } from '../lib/siteSettings.js';
export function InscriptionModal({ rencontre, onClose, onDone }) {
const { user, login } = useAuth();
const [context, setContext] = useState(null);
const [participants, setParticipants] = useState(['']);
// Each participant: { nom, imageConsent } — image consent is recorded per
// person (an adult can't consent for another, art. 9 Code civil).
const [participants, setParticipants] = useState([{ nom: '', imageConsent: true }]);
const [attestation, setAttestation] = useState(false);
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [done, setDone] = useState(false);
const [wasRegistered, setWasRegistered] = useState(false);
@@ -26,7 +29,13 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
.then((d) => {
setContext(d);
setWasRegistered(d.participants.length > 0);
setParticipants(d.participants.length ? d.participants.map((p) => p.nom) : [d.member.dirigeant || '']);
setParticipants(
d.participants.length
? d.participants.map((p) => ({ nom: p.nom, imageConsent: p.image_consent !== false }))
: [{ nom: d.member.dirigeant || '', imageConsent: true }]
);
// A returning registration already carried the attestation.
setAttestation(d.participants.length > 0);
})
.catch((err) => setError(err.message));
}, [user, rencontre.id]);
@@ -45,7 +54,10 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
};
const changeParticipant = (index, value) => {
setParticipants((current) => current.map((name, i) => (i === index ? value : name)));
setParticipants((current) => current.map((p, i) => (i === index ? { ...p, nom: value } : p)));
};
const toggleParticipantImage = (index) => {
setParticipants((current) => current.map((p, i) => (i === index ? { ...p, imageConsent: !p.imageConsent } : p)));
};
const maxPerAccount = context?.rencontre.participants_par_compte || rencontre.participants_par_compte || 1;
@@ -53,9 +65,13 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
const submit = async (e) => {
e.preventDefault();
setError('');
if (!attestation) {
setError("Merci de confirmer l'attestation sur le droit à l'image.");
return;
}
setBusy(true);
try {
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants });
await api.post(`/api/public/rencontres/${rencontre.id}/inscriptions`, { participants, attestation });
setDone(true);
onDone?.();
} catch (err) {
@@ -133,33 +149,40 @@ export function InscriptionModal({ rencontre, onClose, onDone }) {
<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' : ''}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{participants.map((name, index) => (
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<label className="field-plain" style={{ flex: 1 }}>Participant {index + 1} — nom & prénom
<input value={name} onChange={(e) => changeParticipant(index, e.target.value)} required maxLength={120} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{participants.map((p, index) => (
<div key={index} style={{ border: '1px solid var(--border-soft)', borderRadius: 6, padding: '12px 14px' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<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>
{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>
{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
</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>}
<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' : ''}`}
</button>
<p style={{ fontSize: 12, color: 'var(--gray-light)', textAlign: 'center', marginTop: 12, lineHeight: 1.5 }}>
Vous pourrez rouvrir ce formulaire pour modifier les noms enregistrés.
</p>
<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 style={{ fontSize: 11, color: 'var(--gray-light)', textAlign: 'center', marginTop: 10, lineHeight: 1.5 }}>
Vous pourrez rouvrir ce formulaire pour modifier les noms et les choix.{' '}
<Link to="/confidentialite" style={{ color: 'var(--red)' }}>Politique de confidentialité</Link>.
</p>
</form>
)}