Le consentement en ligne

Un membre majeur donne son accord depuis son profil : une majorité qui se vérifie, un mot de passe qui vaut signature et dont les essais sont limités, un récapitulatif PDF figé rangé avec les formulaires papier, la version du texte, et ce qui reste à faire pour les mineurs.

Précédemment. Le formulaire de droit à l’image signé est déposé par un administrateur, rangé hors de l’application, servi à ses seuls ayants droit ; le statut en découle, et la preuve survit cinq ans au départ.

Le formulaire papier reste la voie de tous. Mais remettre une feuille signée à un responsable, la faire scanner, la faire déposer, c’est trois personnes pour un oui. Un membre majeur peut donner son accord lui-même, depuis son profil. Le site doit alors produire ce que produisait la signature : un document, une date, la version du texte accepté, et la certitude que c’est bien lui.

Les parcours du chapitre#

Un parcours traverse ce chapitre : le membre majeur qui donne son accord en ligne.

Acteur : Membre majeur, connecté

  1. 1

    Son profil sait s’il peut confirmer lui-même, ou ce qui manque

    imageConsent.onlineStatemembre

    Lit : User

    Le chemin dans le code

    Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).

    imageConsent.onlineState packages/backend/src/modules/imageConsent/router.ts:73
    • onlineConsentState packages/backend/src/modules/imageConsent/imageConsentOnline.service.ts:70
      • prisma.user.findUnique packages/backend/src/modules/imageConsent/imageConsentOnline.service.ts:71User lue

  2. 2

    Il coche ses usages et confirme avec son mot de passe ; un récapitulatif PDF est produit, rangé, et le statut mis à jour

    imageConsent.confirmMinemembre

    • ImageConsentForm créée : fileName, keepAfterLeave, memberBirthDate, memberName, mimeType, signedAt, signerName, size, source, storageKey, textVersion, usages, userId
    • User modifiée : imageConsent, imageConsentAt

    Lit : ImageConsentForm, User

    E-mails : sendImageConsentNotice

    Le chemin dans le code

    Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).

    imageConsent.confirmMine packages/backend/src/modules/imageConsent/router.ts:76
    • confirmMemberConsent packages/backend/src/modules/imageConsent/imageConsentOnline.service.ts:97
      • prisma.imageConsentForm.create packages/backend/src/modules/imageConsent/imageConsentOnline.service.ts:146ImageConsentForm créée
      • prisma.user.update packages/backend/src/modules/imageConsent/imageConsentOnline.service.ts:164User modifiée

Qui peut confirmer en ligne ✍️#

Seul un membre majeur signe pour lui-même ; pour un mineur, c’est son représentant légal. La majorité est une donnée que le club renseigne (), mais elle change avec le temps : un mineur de dix-sept ans devient majeur un jour, sans que personne ne mette sa fiche à jour.

La majorité effective
TS
packages/contracts/src/legal/imageConsent.ts
export function effectiveLegalAge(  m: { legalAge: LegalAgeValue; birthDate: Date | null; birthDateVerified: boolean },  now: Date = new Date(),): LegalAgeValue {  if (m.legalAge === "MINOR" && m.birthDate && m.birthDateVerified && ageOn(m.birthDate, now) >= 18) return "ADULT";  return m.legalAge;}

Un mineur dont la date de naissance vérifiée atteint dix-huit ans est majeur, même si sa fiche dit encore « mineur ». La vérification compte : une date saisie par le membre lui-même ne suffirait pas à lui faire signer seul (partie « Membres », la date de naissance vérifiée est verrouillée).

Le profil demande au serveur ce qu’il peut proposer :

onlineConsentState
TS
packages/backend/src/modules/imageConsent/imageConsentOnline.service.ts
export async function onlineConsentState(prisma: PrismaClient, userId: string) {  const m = await prisma.user.findUnique({ where: { id: userId }, select: memberSelect });  if (!m) throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable." });  const legalAge = effectiveLegalAge(m);  const missing = legalAge === "MINOR" ? missingForMinor(m) : [];  return {    legalAge,    /** Majeur dont le nom est renseigné : il confirme lui-même, en ligne. */    canConfirmOnline: legalAge === "ADULT" && fullName(m) !== "",    /** Mineur : ce qui manque avant l'envoi au parent et le formulaire pré-rempli. */    missing,    /** Formulaire pré-rempli téléchargeable (mineur : seulement si tout est renseigné). */    canDownloadPrefilled: legalAge !== "MINOR" || missing.length === 0,    birthDateLocked: m.birthDateVerified,    /** Adresse du parent, masquée (le membre ne la voit pas en entier). */    guardianEmailMasked: m.guardianEmail ? maskEmail(m.guardianEmail) : null,  };}

Un majeur dont le nom est renseigné peut confirmer en ligne (le nom figurera sur le récapitulatif). Pour un mineur, le serveur dit ce qui manque avant de pouvoir solliciter son parent (son nom, sa date de naissance vérifiée, le nom et l’e-mail du parent) ; l’adresse du parent est montrée masquée (c•••@example.com) : le membre sait à qui la demande partira, sans lire l’adresse en entier.

La confirmation, pas à pas#

confirmMemberConsent

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Majeur, sinon une explication
TS
packages/backend/src/modules/imageConsent/imageConsentOnline.service.ts
guided extract: lines 97-184focus line 11
import crypto from "crypto";import { assertPasswordOfConnectedUser } from "@backend/lib/security/passwordCheck";import type { PrismaClient } from "@prisma/client";import { TRPCError } from "@trpc/server";import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";import { getR2Bucket, getR2Client } from "@backend/modules/storage/adapters/r2/client";import { sendImageConsentNotice } from "@backend/email/templates/imageConsentNotice";import { buildConsentPdf } from "@backend/modules/imageConsent/consentPdf.service";import { IMAGE_CONSENT_ROOT } from "@backend/modules/imageConsent/imageConsentForm.service";import { latestFormUsages } from "@backend/modules/imageConsent/formOrder";import {  IMAGE_CONSENT_TEXT_VERSION,  IMAGE_USAGE_VALUES,  consentFromUsages,  effectiveLegalAge,  missingForMinor,  type ImageUsageValue,} from "@contracts/legal/imageConsent";/** * Droit à l'image donné EN LIGNE. * * Membre majeur, connecté : il coche ses choix et confirme avec son mot de passe (une session restée * ouverte ne suffit pas : la confirmation est un geste de signature). Le serveur produit le * récapitulatif PDF (le formulaire du club, rempli et figé), le range avec les formulaires papier * (`_private/image-consent/<userId>/`), crée la version (source ONLINE_MEMBER) et met à jour le * statut, puis avise la boîte du club. * * Le circuit du parent référent d'un mineur (lien par e-mail) viendra s'ajouter ici. */const memberSelect = {  firstName: true,  lastName: true,  email: true,  password: true,  birthDate: true,  birthDateVerified: true,  legalAge: true,  guardianName: true,  guardianEmail: true,} as const;function fullName(m: { firstName: string | null; lastName: string | null }): string {  return [m.firstName, m.lastName].filter(Boolean).join(" ").trim();}/** Usages dédoublonnés, dans l'ordre du formulaire. */function normalizeUsages(usages: readonly ImageUsageValue[]): ImageUsageValue[] {  return IMAGE_USAGE_VALUES.filter((u) => usages.includes(u));}/** Range un récapitulatif PDF dans l'espace privé du membre et renvoie sa clé. */async function storeReceipt(userId: string, bytes: Uint8Array): Promise<string> {  const key = `${IMAGE_CONSENT_ROOT}/${userId}/${Date.now()}-${crypto.randomBytes(4).toString("hex")}.pdf`;  await getR2Client().send(    new PutObjectCommand({ Bucket: getR2Bucket(), Key: key, Body: bytes, ContentType: "application/pdf" }),  );  return key;}async function removeObject(key: string): Promise<void> {  await getR2Client()    .send(new DeleteObjectCommand({ Bucket: getR2Bucket(), Key: key }))    .catch(() => undefined);}/** Ce que le profil doit savoir pour proposer (ou non) l'autorisation en ligne. */export async function onlineConsentState(prisma: PrismaClient, userId: string) {  const m = await prisma.user.findUnique({ where: { id: userId }, select: memberSelect });  if (!m) throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable." });  const legalAge = effectiveLegalAge(m);  const missing = legalAge === "MINOR" ? missingForMinor(m) : [];  return {    legalAge,    /** Majeur dont le nom est renseigné : il confirme lui-même, en ligne. */    canConfirmOnline: legalAge === "ADULT" && fullName(m) !== "",    /** Mineur : ce qui manque avant l'envoi au parent et le formulaire pré-rempli. */    missing,    /** Formulaire pré-rempli téléchargeable (mineur : seulement si tout est renseigné). */    canDownloadPrefilled: legalAge !== "MINOR" || missing.length === 0,    birthDateLocked: m.birthDateVerified,    /** Adresse du parent, masquée (le membre ne la voit pas en entier). */    guardianEmailMasked: m.guardianEmail ? maskEmail(m.guardianEmail) : null,  };}/** « c•••@example.com ». */export function maskEmail(email: string): string {  const [local, domain] = email.split("@");  if (!domain) return "•••";  return `${local.slice(0, 1)}•••@${domain}`;}/** Confirmation en ligne par le membre majeur. */export async function confirmMemberConsent(params: {  prisma: PrismaClient;  userId: string;  password: string;  usages: readonly ImageUsageValue[];  keepAfterLeave: boolean | null;}): Promise<{ id: string }> {  const { prisma, userId } = params;  const m = await prisma.user.findUnique({ where: { id: userId }, select: memberSelect });  if (!m) throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable." });  if (effectiveLegalAge(m) !== "ADULT") {    throw new TRPCError({      code: "FORBIDDEN",      message:        m.legalAge === "MINOR"          ? "Pour un membre mineur, l'autorisation est donnée par son parent référent."          : "Votre majorité n'est pas encore renseignée par le club : remettez le formulaire papier, ou demandez à un instructeur de la renseigner.",    });  }  const name = fullName(m);  if (!name) {    throw new TRPCError({ code: "BAD_REQUEST", message: "Renseignez d'abord vos nom et prénom dans votre profil." });  }  // Le mot de passe vaut signature ; ses essais sont limités comme ceux de la connexion.  await assertPasswordOfConnectedUser({    userId,    password: params.password,    hash: m.password,    wrongMessage: "Mot de passe incorrect.",  });  const usages = normalizeUsages(params.usages);  const confirmedAt = new Date();  const pdf = await buildConsentPdf(m, {    usages,    keepAfterLeave: params.keepAfterLeave,    signerName: name,    signerQuality: null,    confirmedAt,    signatureLines: [      `depuis son compte (${m.email}), mot de passe saisi`,      `Texte de l'autorisation : version ${IMAGE_CONSENT_TEXT_VERSION}`,    ],  });  const previous = await latestFormUsages(prisma, userId);  const key = await storeReceipt(userId, pdf);  try {    const [form] = await prisma.$transaction([      prisma.imageConsentForm.create({        data: {          userId,          memberName: name,          storageKey: key,          mimeType: "application/pdf",          fileName: `autorisation-droit-image-${confirmedAt.toISOString().slice(0, 10)}.pdf`,          size: pdf.byteLength,          usages,          keepAfterLeave: params.keepAfterLeave,          signedAt: confirmedAt,          source: "ONLINE_MEMBER",          textVersion: IMAGE_CONSENT_TEXT_VERSION,          signerName: name,          memberBirthDate: m.birthDate,        },        select: { id: true },      }),      prisma.user.update({        where: { id: userId },        data: { imageConsent: consentFromUsages(usages), imageConsentAt: confirmedAt },        select: { id: true },      }),    ]);    await sendImageConsentNotice({      userId,      memberName: name,      via: "en ligne par le membre",      usages,      previousUsages: previous,    }).catch((err) => console.error("[droit à l'image] avis au club non envoyé :", err instanceof Error ? err.message : err));    return { id: form.id };  } catch (err) {    await removeObject(key);    throw err;  }}

Des essais de mot de passe limités#

La connexion limitait déjà ses tentatives. Les vérifications d’un mot de passe par un membre connecté — confirmer ce consentement, changer de mot de passe — ne limitaient rien : une session volée permettait d’essayer des mots de passe sans fin, pour le changer, ou pour l’apprendre (un mot de passe resservant souvent ailleurs).

assertPasswordOfConnectedUser

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Un compteur par membre
TS
packages/backend/src/lib/security/passwordCheck.ts
guided extract: lines 17-36focus line 8
import { TRPCError } from "@trpc/server";import { verifyPassword } from "@backend/lib/auth/password";import { createThrottle } from "@backend/lib/security/throttle";/** * Vérification du mot de passe d'un membre DÉJÀ connecté (changer de mot de passe, confirmer un * consentement en ligne). * * La connexion limite ses tentatives (auth.service, loginThrottle) ; ces vérifications-là ne * limitaient rien. Une session volée (un ordinateur resté ouvert) permettait d'essayer des mots de * passe sans fin, pour le changer ou pour l'apprendre — un mot de passe resservant souvent ailleurs. * 5 essais faux par quart d'heure et par membre ; une réussite remet le compteur à zéro. */const passwordCheckThrottle = createThrottle({ limit: 5, windowMs: 15 * 60 * 1000 });export async function assertPasswordOfConnectedUser(params: {  userId: string;  password: string;  hash: string;  /** Message d'un mot de passe faux (BAD_REQUEST : le membre est connecté, c'est la saisie qui est fausse). */  wrongMessage: string;}): Promise<void> {  const key = `user:${params.userId}`;  if (passwordCheckThrottle.isLimited([key])) {    throw new TRPCError({      code: "TOO_MANY_REQUESTS",      message: "Trop d'essais de mot de passe. Réessayez dans un quart d'heure.",    });  }  if (!(await verifyPassword(params.password, params.hash))) {    passwordCheckThrottle.record([key]);    throw new TRPCError({ code: "BAD_REQUEST", message: params.wrongMessage });  }  passwordCheckThrottle.clear([key]);}

La version du texte#

Une autorisation porte sur un texte. Si le club modifie le formulaire (un usage ajouté, une durée changée), les autorisations déjà données l’ont été sur l’ancien texte. La preuve doit dire lequel :

Une version, à changer avec le texte
TS
packages/contracts/src/legal/imageConsent.ts
/** * Version du texte d'autorisation (formulaire PDF et formulaire en ligne). Enregistrée avec chaque * autorisation donnée en ligne : à changer à chaque modification du texte du formulaire. */export const IMAGE_CONSENT_TEXT_VERSION = "2026-10";

Chaque autorisation en ligne enregistre cette version (), et le récapitulatif l’imprime. La règle est humaine : quiconque modifie le PDF ou le texte du formulaire en ligne change la version dans le même commit.

Le récapitulatif est produit à partir du formulaire vierge lui-même :

Un formulaire rempli par le serveur
TS
packages/backend/src/modules/imageConsent/consentPdf.service.ts
/** * Formulaire d'autorisation de droit à l'image (apps/web/public/documents), rempli par le serveur. * * - PRÉ-REMPLI : identité du membre (nom, prénom, date de naissance, e-mail) et qui signe (le membre *   majeur, ou son représentant légal : case cochée et nom du parent). Le reste est laissé à la main. * - RÉCAPITULATIF d'une autorisation donnée en ligne : en plus, les usages Oui / Non, le choix après *   le départ, « fait à / le », et dans le cadre de signature la mention de la confirmation en ligne. *   Le document est figé (champs aplatis) : c'est la preuve rangée avec les formulaires papier. * * Les noms de champs sont ceux du PDF : ne pas les changer sans mettre ce fichier à jour. */

Le même service sert aussi le formulaire pré-rempli (l’identité du membre, qui signe), que le membre télécharge, imprime et signe à la main. Les noms de champs sont ceux du PDF : un PDF retouché sans mettre ce fichier à jour remplirait des champs qui n’existent plus.

Et les mineurs#

Pour un mineur, l’autorisation est donnée par son parent référent. Le circuit en ligne prévu est un lien envoyé à l’adresse du parent ; il n’est pas encore en place. En attendant :

Situation

Ce que propose le profil

Majeur, nom renseigné
La confirmation en ligne, et le formulaire pré-rempli.
Majorité non renseignée
Le formulaire papier ; un instructeur renseigne la majorité.
Mineur, fiche incomplète
La liste de ce qui manque (nom, date de naissance vérifiée, parent).
Mineur, fiche complète
Le formulaire pré-rempli au nom du parent, à signer et remettre.

Une signature électronique « simple » (le mot de passe du titulaire, une date, un document figé et versionné) prouve moins qu’une signature manuscrite, mais plus qu’une case cochée. Ce qui la rend probante, c’est ce qu’on garde : le document, le texte accepté, qui, quand, et la certitude que c’était lui.

Récapitulatif#

Question

Réponse du projet

Qui peut confirmer en ligne ?
Un membre majeur (majorité effective : saisie, ou date de naissance vérifiée qui atteint 18 ans), dont le nom est renseigné.
Qu’est-ce qui vaut signature ?
Son mot de passe, saisi au moment de confirmer ; une session ouverte ne suffit pas.
Comment les essais sont-ils limités ?
Cinq essais faux par quart d’heure et par membre, comme pour changer de mot de passe.
Que garde le club ?
Un récapitulatif PDF figé, rangé avec les formulaires papier, avec la source, la version du texte, le signataire et la date.
Que se passe-t-il si l’enregistrement échoue ?
Le PDF rangé est supprimé : aucun document sans ligne pour le désigner.
Et pour un mineur ?
Le formulaire pré-rempli au nom du parent, signé à la main ; le lien envoyé au parent reste à faire.

À suivre#

La partie légale est complète : le site dit ce qu’il fait des données, tient ses durées, garde la preuve des autorisations. Restent les mécanismes que toutes les pages partagent : être trouvé, se repérer, être utilisable par tous.

Partie suivante : interface transverse. Le référencement, le header, l’accessibilité.

Sur cette page