From 942bc708654127eca5818f45b2a01abb848b3a95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 20:30:48 +0000 Subject: [PATCH] =?UTF-8?q?Conformit=C3=A9=20RGPD=20:=20registre=20des=20t?= =?UTF-8?q?raitements,=20droit=20=C3=A0=20l'image,=20HTTPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registre des traitements (art. 30 RGPD) et formulaires d'autorisation de droit à l'image (majeur / mineur) générés depuis un nouvel onglet « RGPD » de l'administration (admins uniquement) : documents A4 imprimables et téléchargeables, pré-remplis à partir des Paramètres de l'association. Le registre recense les 7 traitements réels de l'application (adhésions, annuaire, inscriptions, facturation, espace membre, photos, e-mails) avec finalité, base légale, personnes, données, destinataires et durées ; la facturation est conservée 10 ans (obligation comptable, art. L123-22). Politique de confidentialité complétée du traitement de facturation (adresse, factures, paiements), de la conservation comptable de 10 ans et des destinataires (trésoriers, expert-comptable, administration fiscale). HTTPS en production : en-tête HSTS (1 an, includeSubDomains) et redirection HTTP→HTTPS (308) des requêtes reçues via le reverse proxy (X-Forwarded-Proto), sans impacter les appels directs (healthcheck). Nouveau drapeau FORCE_HTTPS dans docker-compose et .env.example, aux côtés de COOKIE_SECURE et TRUST_PROXY documentés pour la production. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o --- .env.example | 12 +- docker-compose.yml | 5 + server/src/complianceDocuments.js | 278 +++++++++++++++++++++++++ server/src/config.js | 5 + server/src/index.js | 20 ++ server/src/routes/admin.js | 23 ++ web/src/components/admin/AdminRgpd.jsx | 96 +++++++++ web/src/pages/Admin.jsx | 3 + web/src/pages/Confidentialite.jsx | 17 +- 9 files changed, 453 insertions(+), 6 deletions(-) create mode 100644 server/src/complianceDocuments.js create mode 100644 web/src/components/admin/AdminRgpd.jsx diff --git a/.env.example b/.env.example index 1b4382b..cf3d37d 100644 --- a/.env.example +++ b/.env.example @@ -22,12 +22,20 @@ ADMIN_INITIAL_PASSWORD=ChangeMe-Admin-2026! # Initial password given to every seeded member account (demo data only) MEMBER_INITIAL_PASSWORD=ChangeMe-Membre-2026! -# Set to "true" when serving over HTTPS (adds Secure flag on cookies) +# --- HTTPS (production) ----------------------------------------------- +# In production, serve the site over HTTPS through your reverse proxy and +# set the three flags below to "true". The proxy must forward the headers +# X-Forwarded-Proto and X-Forwarded-Host to the app. + +# Adds the Secure flag on the session cookie (cookie sent over HTTPS only) COOKIE_SECURE=false -# Set to "true" only when running behind a reverse proxy (TLS termination) +# Lets the app trust X-Forwarded-* headers from the reverse proxy TRUST_PROXY=false +# Redirects any plain-HTTP request (through the proxy) to HTTPS +FORCE_HTTPS=false + # Uncommon ports to avoid collisions with other services # Application (public web port) APP_PORT=8321 diff --git a/docker-compose.yml b/docker-compose.yml index e72b2fe..e2b0812 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,8 +49,13 @@ services: JWT_SECRET: ${JWT_SECRET:-} ADMIN_INITIAL_PASSWORD: ${ADMIN_INITIAL_PASSWORD:-SlucAdmin2026!} MEMBER_INITIAL_PASSWORD: ${MEMBER_INITIAL_PASSWORD:-SlucMembre2026!} + # Production behind an HTTPS reverse proxy: set all three to "true". + # COOKIE_SECURE adds the Secure flag on the session cookie, TRUST_PROXY + # lets the app read X-Forwarded-* from the proxy, FORCE_HTTPS redirects + # any plain-HTTP request to HTTPS. COOKIE_SECURE: ${COOKIE_SECURE:-false} TRUST_PROXY: ${TRUST_PROXY:-false} + FORCE_HTTPS: ${FORCE_HTTPS:-false} UPLOAD_DIR: /data/uploads volumes: - uploads:/data/uploads diff --git a/server/src/complianceDocuments.js b/server/src/complianceDocuments.js new file mode 100644 index 0000000..5bf9625 --- /dev/null +++ b/server/src/complianceDocuments.js @@ -0,0 +1,278 @@ +// GDPR compliance documents generated on demand from the admin backend: +// - the record of processing activities (registre des traitements, art. 30 +// RGPD) — an internal document every association must keep; +// - the image-rights authorization form (autorisation de droit à l'image), +// an adult and a minor variant, to collect at events before publishing +// photographs. +// Both are print-ready A4 HTML documents styled after the site theme, and +// prefilled with the association parameters maintained in Paramètres. +import { associationSettings } from './siteSettings.js'; + +function esc(v) { + return String(v ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} +function escML(v) { + return esc(v).replace(/\r?\n/g, '
'); +} + +const todayFr = () => + new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }); + +// Shared A4 print skeleton. `body` is trusted HTML built below. +function page({ title, body }) { + return ` + + + + +${esc(title)} + + +
${body}
+`; +} + +function orgBlock(s) { + const lines = [ + s.association_address && escML(s.association_address), + s.association_rna && `RNA : ${esc(s.association_rna)}`, + s.association_siret && `SIRET : ${esc(s.association_siret)}`, + s.association_email && esc(s.association_email), + s.association_phone && esc(s.association_phone), + ].filter(Boolean); + return `
${esc(s.association_name)}
+
${lines.join('
')}
`; +} + +const missing = (label) => + `à compléter — ${label}`; +const val = (v, label) => (v ? esc(v) : missing(label)); + +export function buildProcessingRegister({ association = {} } = {}) { + const s = associationSettings(association); + const title = `Registre des traitements — ${s.association_name}`; + + // Each entry mirrors an actual processing activity of the application. + const processings = [ + { + t: '1. Gestion des adhésions et des membres', + finalite: "Instruire les demandes d'adhésion et gérer les entreprises membres du club.", + base: "Exécution du contrat d'adhésion et mesures précontractuelles (demandes).", + personnes: 'Dirigeants et représentants des entreprises membres et candidates.', + donnees: "Identité (nom, prénom, fonction), entreprise et secteur, coordonnées professionnelles (email, téléphone, adresse).", + destinataires: 'Administrateurs, modérateurs et trésoriers habilités de l’association.', + duree: "Durée de l'adhésion, puis 3 ans maximum après son terme (relance) avant suppression. Demandes non abouties : 3 ans maximum.", + }, + { + t: '2. Annuaire public des membres', + finalite: "Publier sur le site public l'annuaire des entreprises membres à jour de leur adhésion.", + base: "Exécution de l'adhésion ; la photographie du dirigeant est publiée sur la base du consentement.", + personnes: 'Dirigeants des entreprises membres validées.', + donnees: 'Entreprise, secteur, dirigeant, coordonnées professionnelles, présentation, logo, photographie.', + destinataires: 'Public (site internet).', + duree: "Durée de l'adhésion ; retrait de la fiche à la fin de l'adhésion ou sur demande.", + }, + { + t: '3. Inscriptions aux rencontres', + finalite: "Gérer les inscriptions aux rencontres et l'accueil des participants.", + base: "Exécution de l'adhésion.", + personnes: 'Participants inscrits par les comptes membres.', + donnees: 'Nom et prénom des participants, rattachement au compte membre inscripteur.', + destinataires: 'Administrateurs, modérateurs et trésoriers habilités.', + duree: "Saison en cours, puis archivage à des fins statistiques (nombre de participants uniquement).", + }, + { + t: '4. Comptabilité et facturation des adhésions', + finalite: "Émettre les factures d'adhésion, suivre les paiements et tenir la comptabilité de l'association.", + base: "Obligation légale (obligations comptables et fiscales) et exécution de l'adhésion.", + personnes: 'Membres facturés.', + donnees: "Identité et adresse de facturation, email, montants (HT, TVA, TTC), moyen et date de paiement, numéro de facture.", + destinataires: "Trésoriers et administrateurs habilités ; expert-comptable et administration fiscale le cas échéant.", + duree: "10 ans à compter de la clôture de l'exercice (art. L123-22 du code de commerce).", + }, + { + t: '5. Espace membre et authentification', + finalite: "Fournir un accès sécurisé à l'espace personnel et à l'administration.", + base: "Exécution de l'adhésion et intérêt légitime (sécurité des accès).", + personnes: 'Membres, administrateurs, modérateurs et trésoriers.', + donnees: 'Email de connexion, mot de passe (stocké haché, jamais en clair).', + destinataires: 'Aucun (usage interne).', + duree: "Supprimé avec le compte de la personne concernée.", + }, + { + t: '6. Photographies des événements', + finalite: "Illustrer la vie du club (rubrique « Rencontres passées » du site).", + base: 'Consentement (droit à l’image) recueilli avant publication.', + personnes: 'Participants photographiés lors des rencontres.', + donnees: 'Image de la personne.', + destinataires: 'Public (site internet).', + duree: "Jusqu'au retrait demandé par la personne ou la fin de l'utilisation.", + }, + { + t: "7. Communications par e-mail (invitations, actualités)", + finalite: "Informer les membres des rencontres et de l'actualité du club.", + base: "Exécution de l'adhésion et intérêt légitime.", + personnes: 'Membres du club.', + donnees: 'Nom et adresse e-mail.', + destinataires: "Aucun ; envoi via l'outil de messagerie de l'association.", + duree: "Durée de l'adhésion.", + }, + ]; + + const secu = `Accès à l'administration restreint aux personnes habilitées et protégé par mot de passe ; mots de passe stockés hachés (bcrypt) ; cookie de session httpOnly et SameSite ; limitation des tentatives de connexion ; connexions chiffrées (HTTPS/TLS) ; validation des fichiers déposés ; sauvegardes de la base de données.`; + + const procHtml = processings + .map( + (p) => `
+
${esc(p.t)}
+ + + + + + + +
Finalité${esc(p.finalite)}
Base légale${esc(p.base)}
Personnes concernées${esc(p.personnes)}
Catégories de données${esc(p.donnees)}
Destinataires${esc(p.destinataires)}
Durée de conservation${esc(p.duree)}
+
` + ) + .join(''); + + const body = ` +
+
+
Registre des traitements · Article 30 RGPD
+

Registre des activités de traitement

+
+
${orgBlock(s)}
+
+

Document interne établi le ${esc(todayFr())}. À conserver et à tenir à jour ; à présenter à la CNIL sur demande.

+ +

Responsable de traitement

+ + + + + + +
Organisme${val(s.association_name, "nom de l'association")}, association loi 1901
Adresse${val(s.association_address, 'adresse du siège')}
Numéro RNA${val(s.association_rna, 'numéro RNA')}
Représentant légal${val(s.association_president, 'président·e')}, président·e
Contact${val(s.association_email, 'email de contact')}${s.association_phone ? ' — ' + esc(s.association_phone) : ''}
+ +

Traitements mis en œuvre

+ ${procHtml} + +

Mesures de sécurité communes

+

${esc(secu)}

+ +
+ Aucune donnée n'est cédée ou vendue à des tiers, ni utilisée à des fins de prospection commerciale. + Aucun transfert de données hors de l'Union européenne n'est réalisé. Le site n'utilise ni outil de + mesure d'audience ni traceur publicitaire. Les personnes concernées disposent des droits d'accès, + de rectification, d'effacement, de limitation, d'opposition et de portabilité, exerçables auprès du + contact ci-dessus. +
`; + + return { title, html: page({ title, body }) }; +} + +export function buildImageConsentForm({ association = {}, minor = false } = {}) { + const s = associationSettings(association); + const who = minor ? "d'un mineur" : 'majeur'; + const title = `Autorisation de droit à l'image (${minor ? 'mineur' : 'majeur'}) — ${s.association_name}`; + + const line = (label, w = '100%') => + `
${label}
`; + + const scope = ` +

J'autorise ${esc(s.association_name)} à fixer, reproduire et diffuser mon image + ${minor ? "(ou celle de l'enfant représenté) " : ''}sur les supports suivants :

+ +

+ Ces images sont réalisées lors des rencontres et manifestations du club. La présente autorisation est + consentie à titre gratuit, pour une durée de 5 ans à compter de la signature, pour une diffusion en + France et à l'étranger. Les images ne seront ni cédées à des tiers ni utilisées à des fins commerciales + ou portant atteinte à la dignité de la personne. +

`; + + const identity = minor + ? `${line('Je soussigné·e (représentant légal) — nom et prénom')} + ${line('Agissant en qualité de (père, mère, tuteur·rice)')} + ${line("Nom et prénom de l'enfant mineur")} + ${line("Date de naissance de l'enfant", '240px')} + ${line('Adresse')}` + : `${line('Je soussigné·e — nom et prénom')} + ${line('Adresse')}`; + + const body = ` +
+
+
Protection des données · Droit à l'image
+

Autorisation de droit à l'image${minor ? '
— personne mineure' : ''}

+
+
${orgBlock(s)}
+
+ +

Identité

+ ${identity} + +

Autorisation

+ ${scope} + +
+

J'autorise l'utilisation de ces images dans les conditions ci-dessus.

+

Je n'autorise pas l'utilisation de mon image${minor ? " / de celle de l'enfant" : ''}.

+
+ +
+
${line('Fait à', '160px')}${line('Le', '160px')}
+
Signature${minor ? ' du représentant légal' : ''} (précédée de « Lu et approuvé »)
+
+
+ +
+ Les informations recueillies sont traitées par ${esc(s.association_name)}, responsable de traitement, + aux seules fins de gestion des autorisations de droit à l'image. Base légale : votre consentement. + Elles sont conservées pendant la durée de l'autorisation puis archivées comme preuve. Vous disposez + d'un droit d'accès, de rectification, d'effacement et de retrait de votre consentement à tout moment, + sans que cela ne remette en cause la licéité des diffusions antérieures, en écrivant à + ${val(s.association_email, 'email de contact')}. Le retrait entraîne le retrait des images concernées + dans les meilleurs délais. Réclamation possible auprès de la CNIL (www.cnil.fr). +
`; + + return { title, html: page({ title, body }) }; +} diff --git a/server/src/config.js b/server/src/config.js index 5a8d973..e704e79 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -38,6 +38,11 @@ export const config = { cookieSecure: process.env.COOKIE_SECURE === 'true', // set to "true" only when running behind a reverse proxy (TLS termination) trustProxy: process.env.TRUST_PROXY === 'true', + // when "true" (production behind a TLS proxy), plain-HTTP requests coming + // through the proxy are 301-redirected to HTTPS. Direct requests without + // an X-Forwarded-Proto header (e.g. the container healthcheck) are never + // redirected, so this stays safe to enable. + forceHttps: process.env.FORCE_HTTPS === 'true', uploadDir: process.env.UPLOAD_DIR || '/data/uploads', staticDir: process.env.STATIC_DIR || new URL('../public', import.meta.url).pathname, adminInitialPassword: process.env.ADMIN_INITIAL_PASSWORD || '', diff --git a/server/src/index.js b/server/src/index.js index 2ec3203..f45d9b6 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -20,8 +20,28 @@ const app = express(); app.disable('x-powered-by'); if (config.trustProxy) app.set('trust proxy', 1); +// Force HTTPS in production: redirect plain-HTTP requests that reach us +// through the reverse proxy. Requests without an X-Forwarded-Proto header +// (direct localhost calls such as the container healthcheck) are left +// untouched so they keep working over HTTP. +if (config.forceHttps) { + app.use((req, res, next) => { + const proto = req.headers['x-forwarded-proto']; + if (proto && proto.split(',')[0].trim() === 'http') { + return res.redirect(308, `https://${req.headers.host}${req.originalUrl}`); + } + next(); + }); +} + app.use( helmet({ + // HSTS: tell browsers to only ever reach the site over HTTPS. Ignored + // by browsers on plain-HTTP responses, so it is safe to always send. + strictTransportSecurity: { + maxAge: 31536000, // 1 year + includeSubDomains: true, + }, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 69bf9df..74540ec 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -22,6 +22,7 @@ import { createStaffUser, resetStaffAccess } from '../userAccess.js'; import { AccessError } from '../errors.js'; import { buildInvitationEmail } from '../emailTemplate.js'; import { buildCustomEmail } from '../customEmailTemplate.js'; +import { buildProcessingRegister, buildImageConsentForm } from '../complianceDocuments.js'; import { billingRouter } from './billing.js'; export const adminRouter = Router(); @@ -296,6 +297,28 @@ adminRouter.post('/emails/preview', adminOnly, validate(customEmailSchema), asyn } }); +// ---------- GDPR compliance documents (admin only) ---------- +// Print-ready HTML documents prefilled from the association parameters: +// the record of processing activities (art. 30 RGPD) and the image-rights +// authorization form (adult / minor variant). +adminRouter.get('/compliance/register', adminOnly, async (_req, res, next) => { + try { + const association = await loadAssociationSettings(); + res.json(buildProcessingRegister({ association })); + } catch (err) { + next(err); + } +}); + +adminRouter.get('/compliance/image-consent', adminOnly, async (req, res, next) => { + try { + const association = await loadAssociationSettings(); + res.json(buildImageConsentForm({ association, minor: req.query.minor === '1' })); + } catch (err) { + next(err); + } +}); + // ---------- Rencontres passées (admin + moderator manage; admin-only delete) ---------- // Powers the "Rencontres passées" section on the home page and its dedicated // public page. Participant count is derived from the linked rencontre's diff --git a/web/src/components/admin/AdminRgpd.jsx b/web/src/components/admin/AdminRgpd.jsx new file mode 100644 index 0000000..d79e97e --- /dev/null +++ b/web/src/components/admin/AdminRgpd.jsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../lib/api.js'; + +// Admin tab grouping the association's GDPR compliance documents. Each is +// generated server-side (prefilled from the association parameters) and can +// be previewed, printed and downloaded as a standalone HTML file. +const DOCS = [ + { key: 'register', label: 'Registre des traitements', endpoint: '/api/admin/compliance/register', + hint: "Document interne obligatoire (art. 30 RGPD) recensant tous les traitements de données. À conserver et présenter à la CNIL sur demande." }, + { key: 'consent-adult', label: 'Droit à l’image — majeur', endpoint: '/api/admin/compliance/image-consent', + hint: 'Formulaire à faire signer aux participants avant de publier des photos les représentant.' }, + { key: 'consent-minor', label: 'Droit à l’image — mineur', endpoint: '/api/admin/compliance/image-consent?minor=1', + hint: 'Variante à faire signer par le représentant légal pour toute personne mineure.' }, +]; + +export function RgpdTab() { + const [active, setActive] = useState('register'); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + + const doc = DOCS.find((d) => d.key === active); + + useEffect(() => { + setData(null); + setError(''); + api.get(doc.endpoint).then(setData).catch((e) => setError(e.message)); + }, [active]); + + const print = () => { + const w = window.open('', '_blank'); + if (!w) return; + w.document.open(); + w.document.write(data.html); + w.document.close(); + setTimeout(() => { w.focus(); w.print(); }, 350); + }; + + const download = () => { + const blob = new Blob([data.html], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${active}.html`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 2000); + }; + + return ( +
+
+

Conformité RGPD

+
+ Documents générés automatiquement à partir des Paramètres de l'association. + Complétez-y les informations manquantes (RNA, président·e, hébergeur…) pour des documents conformes. +
+
+ +
+ {DOCS.map((d) => ( + + ))} +
+ +
+ + + {doc.hint} +
+ + {error &&

{error}

} + {!data && !error &&

Génération du document…

} + {data && ( +
+