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 `
+
+
`;
+}
+
+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
+ 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 :
+
+
Site internet de l'association
+
Réseaux sociaux de l'association (Facebook, LinkedIn, Instagram…)
+
Supports de communication imprimés (plaquettes, presse, affichage)
+
+
+ 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 && (
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/pages/Admin.jsx b/web/src/pages/Admin.jsx
index db297a7..5db72b2 100644
--- a/web/src/pages/Admin.jsx
+++ b/web/src/pages/Admin.jsx
@@ -9,6 +9,7 @@ import { UsersTab } from '../components/admin/AdminUsers.jsx';
import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx';
import { RequestsTab } from '../components/admin/AdminRequests.jsx';
import { BillingTab } from '../components/admin/AdminBilling.jsx';
+import { RgpdTab } from '../components/admin/AdminRgpd.jsx';
// Moderators get members/membership requests/rencontres/inscriptions/rencontres passées
// (create + edit, never delete) — everything else (dashboard, taxonomy,
@@ -25,6 +26,7 @@ const TABS = [
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] },
{ key: 'parametres', icon: '⚙', label: 'Paramètres', title: "Paramètres de l'association", roles: ['admin'] },
+ { key: 'rgpd', icon: '⚖', label: 'RGPD', title: 'Conformité RGPD', roles: ['admin'] },
{ key: 'emails', icon: '✉', label: "Créateur d'e-mail", title: "Créateur d'e-mail", roles: ['admin'] },
{ key: 'utilisateurs', icon: '⚿', label: 'Utilisateurs', title: 'Administrateurs, trésoriers & modérateurs', roles: ['admin'] },
];
@@ -161,6 +163,7 @@ export function AdminShell() {
{tab === 'categories' && }
{tab === 'contenu' && }
{tab === 'parametres' && }
+ {tab === 'rgpd' && }
{tab === 'emails' && }
{tab === 'utilisateurs' && }
diff --git a/web/src/pages/Confidentialite.jsx b/web/src/pages/Confidentialite.jsx
index 8ec908c..611cd86 100644
--- a/web/src/pages/Confidentialite.jsx
+++ b/web/src/pages/Confidentialite.jsx
@@ -50,6 +50,13 @@ export default function Confidentialite() {
Finalité : annuaire public des entreprises membres. Base légale : exécution de l'adhésion ; la photo est publiée avec le consentement de la personne, qui peut la retirer à tout moment depuis son espace membre.
+
+ Facturation des adhésions
+
+ Identité, adresse de facturation, email, montants, moyen et date de paiement, numéro de facture.
+ Finalité : émettre les factures d'adhésion, suivre les paiements et tenir la comptabilité. Base légale : obligation légale (comptable et fiscale) et exécution de l'adhésion.
+
+
Espace membre
@@ -71,15 +78,17 @@ export default function Confidentialite() {
Les demandes d'adhésion non abouties sont supprimées au plus tard 3 ans après leur dépôt.
Les inscriptions aux rencontres sont conservées le temps de la saison en cours puis archivées
à des fins de statistiques de fréquentation (nombre de participants uniquement).
- Les comptes de connexion sont supprimés avec la fiche du membre.
+ Les factures et pièces comptables sont conservées 10 ans, conformément aux obligations
+ comptables et fiscales. Les comptes de connexion sont supprimés avec la fiche du membre.
- Les données sont accessibles aux seuls administrateurs et modérateurs habilités de
- l'association, soumis à une obligation de confidentialité. Les fiches de l'annuaire et les
- photographies des rencontres sont, elles, publiées sur le site public.
+ Les données sont accessibles aux seuls administrateurs, trésoriers et modérateurs habilités de
+ l'association, soumis à une obligation de confidentialité ; les données comptables peuvent être
+ communiquées à l'expert-comptable et à l'administration fiscale le cas échéant. Les fiches de
+ l'annuaire et les photographies des rencontres sont, elles, publiées sur le site public.