Conformité RGPD : registre des traitements, droit à l'image, HTTPS
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
9 files changed
+453
-6
No files matched your search
+10
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
function escML(v) {
|
||||
return esc(v).replace(/\r?\n/g, '<br>');
|
||||
}
|
||||
|
||||
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 `<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${esc(title)}</title>
|
||||
<style>
|
||||
:root { --red:#C1272D; --ink:#1B1B1B; --gray:#5A544E; --light:#F4F1EC; --border:#E7E2DB; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:#f0eee9; color:var(--ink); font-family:Arial,Helvetica,sans-serif; font-size:13px; line-height:1.55; }
|
||||
.sheet { background:#fff; max-width:820px; margin:24px auto; padding:44px 48px 56px; box-shadow:0 6px 30px -12px rgba(0,0,0,.3); }
|
||||
h1 { font-family:Georgia,'Times New Roman',serif; font-size:26px; margin:0 0 4px; }
|
||||
h2 { font-size:15px; margin:26px 0 10px; padding-bottom:6px; border-bottom:2px solid var(--red); color:var(--red); text-transform:uppercase; letter-spacing:.04em; }
|
||||
.kicker { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--red); font-weight:bold; }
|
||||
.head { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; border-bottom:3px solid var(--ink); padding-bottom:16px; margin-bottom:8px; }
|
||||
.head .org { font-family:Georgia,serif; font-size:18px; font-weight:bold; }
|
||||
.muted { color:var(--gray); }
|
||||
.small { font-size:11.5px; }
|
||||
table { width:100%; border-collapse:collapse; margin:8px 0 4px; }
|
||||
.reg th, .reg td { text-align:left; vertical-align:top; padding:9px 10px; border:1px solid var(--border); font-size:12px; }
|
||||
.reg th { background:var(--light); width:210px; font-weight:bold; color:var(--ink); }
|
||||
.proc { border:1px solid var(--border); border-left:3px solid var(--red); border-radius:4px; margin:0 0 16px; page-break-inside:avoid; }
|
||||
.proc > .t { background:var(--light); padding:10px 14px; font-weight:bold; font-size:13.5px; }
|
||||
.field-line { border-bottom:1px solid #9A938B; min-height:20px; display:inline-block; }
|
||||
.box { display:inline-block; width:15px; height:15px; border:1.5px solid var(--ink); vertical-align:-3px; margin-right:7px; }
|
||||
.sign { margin-top:14px; display:flex; gap:40px; }
|
||||
.sign > div { flex:1; }
|
||||
.note { background:var(--light); border-radius:4px; padding:14px 16px; font-size:11.5px; color:var(--gray); margin-top:18px; }
|
||||
ul { margin:6px 0; padding-left:20px; }
|
||||
li { margin:3px 0; }
|
||||
@media print {
|
||||
body { background:#fff; }
|
||||
.sheet { box-shadow:none; margin:0; max-width:none; padding:0 6mm; }
|
||||
@page { margin:14mm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body><div class="sheet">${body}</div></body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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 `<div class="org">${esc(s.association_name)}</div>
|
||||
<div class="small muted" style="margin-top:4px;">${lines.join('<br>')}</div>`;
|
||||
}
|
||||
|
||||
const missing = (label) =>
|
||||
`<span style="background:#FBEFD9;color:#8A5A22;padding:0 6px;border-radius:3px;font-weight:bold;">à compléter — ${label}</span>`;
|
||||
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) => `<div class="proc">
|
||||
<div class="t">${esc(p.t)}</div>
|
||||
<table class="reg">
|
||||
<tr><th>Finalité</th><td>${esc(p.finalite)}</td></tr>
|
||||
<tr><th>Base légale</th><td>${esc(p.base)}</td></tr>
|
||||
<tr><th>Personnes concernées</th><td>${esc(p.personnes)}</td></tr>
|
||||
<tr><th>Catégories de données</th><td>${esc(p.donnees)}</td></tr>
|
||||
<tr><th>Destinataires</th><td>${esc(p.destinataires)}</td></tr>
|
||||
<tr><th>Durée de conservation</th><td>${esc(p.duree)}</td></tr>
|
||||
</table>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const body = `
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="kicker">Registre des traitements · Article 30 RGPD</div>
|
||||
<h1>Registre des activités de traitement</h1>
|
||||
</div>
|
||||
<div style="text-align:right;">${orgBlock(s)}</div>
|
||||
</div>
|
||||
<p class="small muted">Document interne établi le ${esc(todayFr())}. À conserver et à tenir à jour ; à présenter à la CNIL sur demande.</p>
|
||||
|
||||
<h2>Responsable de traitement</h2>
|
||||
<table class="reg">
|
||||
<tr><th>Organisme</th><td>${val(s.association_name, "nom de l'association")}, association loi 1901</td></tr>
|
||||
<tr><th>Adresse</th><td>${val(s.association_address, 'adresse du siège')}</td></tr>
|
||||
<tr><th>Numéro RNA</th><td>${val(s.association_rna, 'numéro RNA')}</td></tr>
|
||||
<tr><th>Représentant légal</th><td>${val(s.association_president, 'président·e')}, président·e</td></tr>
|
||||
<tr><th>Contact</th><td>${val(s.association_email, 'email de contact')}${s.association_phone ? ' — ' + esc(s.association_phone) : ''}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>Traitements mis en œuvre</h2>
|
||||
${procHtml}
|
||||
|
||||
<h2>Mesures de sécurité communes</h2>
|
||||
<p class="small">${esc(secu)}</p>
|
||||
|
||||
<div class="note">
|
||||
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.
|
||||
</div>`;
|
||||
|
||||
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%') =>
|
||||
`<div style="margin:10px 0;"><span class="small muted">${label}</span><br><span class="field-line" style="width:${w};"></span></div>`;
|
||||
|
||||
const scope = `
|
||||
<p style="margin:14px 0 6px;">J'autorise ${esc(s.association_name)} à fixer, reproduire et diffuser mon image
|
||||
${minor ? "(ou celle de l'enfant représenté) " : ''}sur les supports suivants :</p>
|
||||
<ul style="list-style:none;padding-left:2px;">
|
||||
<li><span class="box"></span> Site internet de l'association</li>
|
||||
<li><span class="box"></span> Réseaux sociaux de l'association (Facebook, LinkedIn, Instagram…)</li>
|
||||
<li><span class="box"></span> Supports de communication imprimés (plaquettes, presse, affichage)</li>
|
||||
</ul>
|
||||
<p class="small muted" style="margin-top:4px;">
|
||||
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.
|
||||
</p>`;
|
||||
|
||||
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 = `
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="kicker">Protection des données · Droit à l'image</div>
|
||||
<h1>Autorisation de droit à l'image${minor ? '<br><span style="font-size:16px;font-weight:normal;" class="muted">— personne mineure</span>' : ''}</h1>
|
||||
</div>
|
||||
<div style="text-align:right;">${orgBlock(s)}</div>
|
||||
</div>
|
||||
|
||||
<h2>Identité</h2>
|
||||
${identity}
|
||||
|
||||
<h2>Autorisation</h2>
|
||||
${scope}
|
||||
|
||||
<div style="margin-top:8px;">
|
||||
<p><span class="box"></span> <strong>J'autorise</strong> l'utilisation de ces images dans les conditions ci-dessus.</p>
|
||||
<p><span class="box"></span> <strong>Je n'autorise pas</strong> l'utilisation de mon image${minor ? " / de celle de l'enfant" : ''}.</p>
|
||||
</div>
|
||||
|
||||
<div class="sign">
|
||||
<div>${line('Fait à', '160px')}${line('Le', '160px')}</div>
|
||||
<div><div class="small muted" style="margin:10px 0 0;">Signature${minor ? ' du représentant légal' : ''} (précédée de « Lu et approuvé »)</div>
|
||||
<div style="border:1px solid var(--border);height:90px;border-radius:4px;margin-top:6px;"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
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).
|
||||
</div>`;
|
||||
|
||||
return { title, html: page({ title, body }) };
|
||||
}
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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'"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Conformité RGPD</h3>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3, lineHeight: 1.55, maxWidth: 720 }}>
|
||||
Documents générés automatiquement à partir des <strong>Paramètres de l'association</strong>.
|
||||
Complétez-y les informations manquantes (RNA, président·e, hébergeur…) pour des documents conformes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
{DOCS.map((d) => (
|
||||
<button
|
||||
key={d.key}
|
||||
className="btn btn-sm"
|
||||
onClick={() => setActive(d.key)}
|
||||
style={{
|
||||
fontSize: 13, padding: '10px 16px',
|
||||
background: active === d.key ? 'var(--red)' : 'var(--admin-bg)',
|
||||
color: active === d.key ? '#fff' : 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<button className="btn btn-red btn-sm" style={{ fontSize: 13.5, padding: '11px 18px' }} onClick={print} disabled={!data}>
|
||||
⎙ Imprimer
|
||||
</button>
|
||||
<button className="btn btn-outline-soft btn-sm" style={{ fontSize: 13.5, padding: '11px 18px' }} onClick={download} disabled={!data}>
|
||||
↓ Télécharger (.html)
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: 'var(--gray-light)', lineHeight: 1.5, flex: 1, minWidth: 240 }}>{doc.hint}</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{!data && !error && <p style={{ color: 'var(--gray-light)' }}>Génération du document…</p>}
|
||||
{data && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<iframe title="Aperçu du document" srcDoc={data.html} style={{ display: 'block', width: '100%', height: 680, border: 'none', background: '#f0eee9' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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' && <CategoriesTab />}
|
||||
{tab === 'contenu' && <ContenuTab />}
|
||||
{tab === 'parametres' && <ParametresTab />}
|
||||
{tab === 'rgpd' && <RgpdTab />}
|
||||
{tab === 'emails' && <EmailCreatorTab />}
|
||||
{tab === 'utilisateurs' && <UsersTab />}
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,13 @@ export default function Confidentialite() {
|
||||
<br /><em>Finalité :</em> annuaire public des entreprises membres. <em>Base légale :</em> 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.
|
||||
</span>
|
||||
</div>
|
||||
<div style={row}>
|
||||
<span style={cellTitle}>Facturation des adhésions</span>
|
||||
<span>
|
||||
Identité, adresse de facturation, email, montants, moyen et date de paiement, numéro de facture.
|
||||
<br /><em>Finalité :</em> émettre les factures d'adhésion, suivre les paiements et tenir la comptabilité. <em>Base légale :</em> obligation légale (comptable et fiscale) et exécution de l'adhésion.
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ ...row, borderBottom: 'none' }}>
|
||||
<span style={cellTitle}>Espace membre</span>
|
||||
<span>
|
||||
@@ -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.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Destinataires et hébergement">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
Les données sont hébergées par <LegalValue value={s.association_host_name} label="nom de l'hébergeur" />,{' '}
|
||||
|
||||
Reference in new issue
Block a user