Le compte et le profil
Un compte ouvert par le club avec un mot de passe robuste qui ne parvient qu’au membre, une première connexion qui le guide vers son profil, un profil que seul son titulaire modifie, un nom affiché qui ne livre jamais l’adresse, et un compte qui part sans rien laisser.
Précédemment. Les disciplines, les cours, les stages et les événements sont les contenus du club ; les pages, le texte riche et les fichiers qu’ils montrent ont leurs propres règles. Les membres, eux, ont leur propre espace sur le site.
Cette partie suit un membre à travers ce qui lui est réservé : son compte et son profil, son avatar, les documents que le club lui adresse, les groupes auxquels il appartient, les espaces où ces groupes travaillent, et les demandes qu’il adresse au club. Ce chapitre commence par son arrivée.
Les parcours du chapitre#
Trois cas d’utilisation traversent ce chapitre. Chaque onglet en déroule les étapes et ce qu’elles écrivent en base, relevé dans le code ; chaque étape mène à son explication plus bas.
Acteur : Administrateur
- 1
L'administrateur crée le compte, rattaché à au moins un groupe ; le serveur génère le mot de passe
user.createadmin- MemberGroupMembership créée
- User créée : email, memberGroupMemberships, password
Lit : MemberGroup, SiteSettings, User
E-mails : sendWelcomeEmail, sendWelcomeNotice
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).
user.createpackages/backend/src/modules/users/router.ts:123ctx.prisma.user.createpackages/backend/src/modules/users/router.ts:158MemberGroupMembership crééeUser créée
- 2
Le mot de passe part au seul membre ; la boîte du club reçoit un avis sans mot de passe
sendWelcomeEmailLit : SiteSettings
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).
sendWelcomeEmailpackages/backend/src/email/templates/welcomeEmailWithPassword.ts:43siteNamespackages/backend/src/email/templates/welcomeEmailWithPassword.ts:31prisma.siteSettings.findUniquepackages/backend/src/email/templates/welcomeEmailWithPassword.ts:32SiteSettings lue
clubContactAddresspackages/backend/src/email/templates/welcomeEmailWithPassword.ts:24prisma.siteSettings .findUniquepackages/backend/src/email/templates/welcomeEmailWithPassword.ts:25SiteSettings lue
Un compte créé par le club 🥋#
Le site n’a pas d’inscription libre. Un membre s’inscrit au club auprès de la MJC, et c’est un administrateur qui lui ouvre un compte :
user.create — ouvrir un compte
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { Prisma } from "@prisma/client";import { router, protectedProcedure, publicProcedure } from "@backend/trpc/core";import { isAdmin } from "@backend/trpc/middleware";import { z } from "zod";import generateStrongPassword from "@backend/lib/security/generatePassword";import { hashPassword } from "@backend/lib/auth/password";import { sendWelcomeEmail, sendWelcomeNotice } from "@backend/email/templates/welcomeEmailWithPassword";import { isDemoEmail } from "@backend/lib/demoAccounts";import { TRPCError } from "@trpc/server";import type { UserProfile } from "@contracts/users/user-profile.types";import type { ImageConsentProofValue } from "@contracts/legal/imageConsent";import { legalInfoInput, legalInfoData } from "@backend/modules/users/legalInfo";import { emailSchema } from "@contracts/auth/auth.schema";import { syncPageMediaReferences } from "@backend/modules/media/services/syncPageMediaReferences.service";import { pageContentSchemaV1 } from "@contracts/page";import { assertBioMediaAllowed, isInstructorUser } from "@backend/modules/users/instructorBio.service";import { removeAvatarFile } from "@backend/modules/avatar/avatar.service";import { resolvePersoBaseFolder } from "@backend/modules/media/services/resolvePersoBaseFolder.service";import { assertOwnAvatars } from "@backend/modules/media/services/assertOwnAvatars.service";import { byEffectiveDesc } from "@backend/modules/imageConsent/formOrder";/** Les champs du profil, et eux seuls : ni l'empreinte du mot de passe, ni les données légales. */const userProfileSelect = { id: true, email: true, firstName: true, lastName: true, pseudo: true, avatar: true, aboutMe: true, phone: true, birthDate: true, isFirstLogin: true,} satisfies Prisma.UserSelect;type UserProfileRow = Prisma.UserGetPayload<{ select: typeof userProfileSelect }>;/** * Du membre lu en base au contrat partagé avec le front : la date devient « AAAA-MM-JJ ». * `satisfies` vérifie la conformité à UserProfile sans élargir le type : le front garde * `id: string` (sûr), là où le contrat, plus large, admettrait `null`. */function toUserProfile(user: UserProfileRow) { return { ...user, birthDate: user.birthDate ? user.birthDate.toISOString().split("T")[0] : null, } satisfies UserProfile;}export const userRouter = router({ getAll: protectedProcedure .use(isAdmin) .query(async ({ ctx }) => { // Jamais l'empreinte du mot de passe vers le navigateur. const users = await ctx.prisma.user.findMany({ orderBy: { id: "asc" }, omit: { password: true }, include: { memberGroupMemberships: { select: { group: { select: { id: true, name: true, isAdminGroup: true } } }, }, // Droit à l'image : voie de la version la plus récente (par date des droits), et demande en // attente du parent. imageConsentForms: { select: { source: true, signedAt: true, createdAt: true } }, imageConsentRequest: { select: { expiresAt: true } }, }, }); const now = new Date(); return users.map(({ memberGroupMemberships, imageConsentForms, imageConsentRequest, ...u }) => ({ ...u, isDemo: isDemoEmail(u.email), consentProof: (imageConsentForms.sort(byEffectiveDesc)[0]?.source ?? (imageConsentRequest?.expiresAt && imageConsentRequest.expiresAt > now ? "PENDING_GUARDIAN" : "NONE")) satisfies ImageConsentProofValue, // Groupes du membre : Administrateurs d'abord, puis ordre alphabétique. groups: memberGroupMemberships .map((m) => m.group) .sort((a, b) => Number(b.isAdminGroup) - Number(a.isAdminGroup) || a.name.localeCompare(b.name, "fr")), })); }), getById: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { id: input.id }, omit: { password: true }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return user; }), getByEmail: protectedProcedure .use(isAdmin) .input(z.object({ email: emailSchema })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { email: input.email }, omit: { password: true }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return user; }), create: protectedProcedure .use(isAdmin) .input( z.object({ // Même règle que la connexion : nettoyée, en minuscules (sinon le membre ne retrouverait // pas son compte en tapant son adresse autrement). email: emailSchema, /** Un ou plusieurs groupes (au moins un). */ groupIds: z.array(z.string().min(1)).min(1, "Choisissez au moins un groupe."), /** Majorité et, pour un mineur, date de naissance et parent référent (tous obligatoires). */ legal: legalInfoInput, }), ) .mutation(async ({ ctx, input }) => { const groupIds = [...new Set(input.groupIds)]; const found = await ctx.prisma.memberGroup.count({ where: { id: { in: groupIds } } }); if (found !== groupIds.length) { throw new TRPCError({ code: "BAD_REQUEST", message: "Groupe inconnu : rechargez la page." }); } // Mot de passe généré côté serveur (jamais saisi par l'admin), robuste par // construction, puis envoyé au SEUL membre (voir welcomeEmailWithPassword.ts). // isFirstLogin=true (défaut Prisma) : le site lui propose de compléter son profil. const password = generateStrongPassword(); const hash = await hashPassword(password); if (input.legal.legalAge === "UNKNOWN") { throw new TRPCError({ code: "BAD_REQUEST", message: "Indiquez si le membre est majeur ou mineur." }); } const legal = legalInfoData(input.legal, input.email, { requireComplete: true }); // L'adresse est unique en base (@unique). Plutôt que de vérifier avant d'écrire (deux // créations simultanées passeraient toutes les deux la vérification), on écrit, et on // traduit le refus de la base. let user; try { user = await ctx.prisma.user.create({ data: { email: input.email, password: hash, ...legal, memberGroupMemberships: { create: groupIds.map((id) => ({ group: { connect: { id } }, access: "EDITOR" as const, })), }, }, omit: { password: true }, }); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { throw new TRPCError({ code: "CONFLICT", message: "Un membre utilise déjà cette adresse e-mail." }); } throw err; } // E-mail de bienvenue au membre, puis avis SANS mot de passe à la boîte du club. // Un échec d'envoi n'annule pas la création : il est journalisé et signalé au club. // Compte de démonstration : son adresse ne peut rien recevoir, pas d'e-mail. if (!isDemoEmail(input.email)) { let sendError: string | null = null; try { await sendWelcomeEmail({ to: input.email, password }); } catch (err) { sendError = err instanceof Error ? err.message : String(err); console.error("[user.create] e-mail de bienvenue non envoyé :", sendError); } const [groups, admin] = await Promise.all([ ctx.prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { name: true } }), ctx.prisma.user.findUnique({ where: { id: ctx.sessionClient.user.id }, select: { firstName: true, lastName: true, email: true }, }), ]); const adminName = admin ? [admin.firstName, admin.lastName].filter(Boolean).join(" ").trim() || admin.email : null; await sendWelcomeNotice({ kind: sendError ? "send-failed" : "sent", memberEmail: input.email, groupNames: groups.map((g) => g.name), createdBy: adminName, detail: sendError, }).catch((err) => console.error("[user.create] avis au club non envoyé :", err instanceof Error ? err.message : err), ); } return { success: true, user, }; }), /** * Majorité, date de naissance et parent référent (admins). Un mineur peut être enregistré avant * que tout soit connu (tri des comptes existants) : l'envoi au parent reste bloqué tant qu'il * manque quelque chose. Majeur : les coordonnées du parent sont effacées. */ setLegalInfo: protectedProcedure .use(isAdmin) .input(z.object({ userId: z.string().min(1), legal: legalInfoInput })) .mutation(async ({ ctx, input }) => { const u = await ctx.prisma.user.findUnique({ where: { id: input.userId }, select: { email: true } }); if (!u) throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable." }); await ctx.prisma.user.update({ where: { id: input.userId }, data: legalInfoData(input.legal, u.email, { requireComplete: false }), select: { id: true }, }); return { ok: true }; }), /** Droit à l'image (admins) : autorisation recueillie par le club pour ce membre. */ setImageConsent: protectedProcedure .use(isAdmin) .input(z.object({ userId: z.string().min(1), consent: z.enum(["UNKNOWN", "GRANTED", "REFUSED"]) })) .mutation(async ({ ctx, input }) => { await ctx.prisma.user.update({ where: { id: input.userId }, data: { imageConsent: input.consent, imageConsentAt: new Date() }, select: { id: true }, }); return { ok: true }; }), delete: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Règle métier, pas question de droit : l'administrateur a le droit de supprimer un compte, // mais pas le sien (il perdrait l'accès, et le club pourrait se retrouver sans administrateur). // L'interface désactive déjà le bouton ; c'est ici que la règle est garantie. if (input.id === ctx.sessionClient.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez pas supprimer votre propre compte." }); } const avatar = (await ctx.prisma.user.findUnique({ where: { id: input.id }, select: { avatar: true } }))?.avatar; const deleted = await ctx.prisma.$transaction(async (tx) => { // Formulaires de droit à l'image signés : gardés 5 ans comme preuve (purge nocturne). await tx.imageConsentForm.updateMany({ where: { userId: input.id }, data: { detachedAt: new Date() } }); // `pageId` n'a pas de clé étrangère : sans ce nettoyage, les liens de // la bio bloqueraient pour toujours la suppression de ses images. await syncPageMediaReferences(tx, { pageType: "INSTRUCTOR_BIO", pageId: input.id, newContent: null, }); // Seul l'identifiant repart vers le navigateur : la ligne complète contient l'empreinte // du mot de passe. return tx.user.delete({ where: { id: input.id }, select: { id: true }, }); }); // Son portrait part avec le compte : un avatar n'est rattaché qu'à lui (aucune ligne // MediaAsset ne le retient), il resterait sinon dans le stockage sans que rien ne l'atteigne. // Après la transaction : un fichier ne se restaure pas si la base annule. if (avatar) await removeAvatarFile(avatar); return deleted; }), getCurrentUserProfile: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const user = await ctx.prisma.user.findUnique({ where: { id: userId }, select: userProfileSelect, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return toUserProfile(user); }), getProfileById: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { id: input.id }, select: userProfileSelect, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return toUserProfile(user); }), /** * L'utilisateur courant est-il titulaire, et quelle est sa bio ? * * Alimente l'éditeur du profil : la section n'apparaît que pour un * titulaire, et se pré-remplit avec ce qu'il a déjà écrit. Le critère de * titulaire est le MEME OR que `getInstructors` / `listPublicInstructors`. */ getMyInstructorState: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const [user, isInstructor] = await Promise.all([ ctx.prisma.user.findUnique({ where: { id: userId }, select: { instructorBio: true, instructorOrder: true }, }), isInstructorUser(ctx.prisma, userId), ]); return { isInstructor, bio: user?.instructorBio ?? null, order: user?.instructorOrder ?? null, // Où le sélecteur de médias s'ouvre : toute la bibliothèque pour un administrateur, son // espace personnel pour un autre membre (le seul dont il puisse poser les fichiers). mediaRoot: ctx.sessionClient.user.isAdmin ? ctx.appRoot : await resolvePersoBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId }), }; }), /** * Un instructeur enregistre SA PROPRE présentation. * * La cible est toujours `ctx.sessionClient.user.id`, jamais un id reçu en * entrée : la garde « c'est mon profil » est structurelle. Aucune * permission d'administration n'entre en jeu — on n'édite que soi. * * `bio` est un document du constructeur de pages (validé par son schéma), ou `null` * pour retirer sa présentation. L'ordre est optionnel. */ saveMyInstructorBio: protectedProcedure .input( z.object({ // Le même contrat que tout contenu du constructeur de pages : un document invalide est // refusé à l'entrée, au lieu d'être enregistré tel quel. bio: pageContentSchemaV1.nullable(), order: z.number().int().nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { assertOwnAvatars(ctx.sessionClient.user.id, input.bio); const userId = ctx.sessionClient.user.id; // Seul un titulaire a une présentation publique. Un autre membre pourrait sinon référencer des // fichiers, et les rendre servis au public, sans qu'aucune page ne les montre. if (!(await isInstructorUser(ctx.prisma, userId))) { throw new TRPCError({ code: "FORBIDDEN", message: "La présentation est réservée aux instructeurs." }); } if (!ctx.sessionClient.user.isAdmin) { // L'ordre de la page publique est une décision du club : un instructeur ne s'y place pas // lui-même en tête. if (input.order !== undefined) { throw new TRPCError({ code: "FORBIDDEN", message: "L'ordre d'affichage est fixé par un administrateur." }); } if (input.bio !== null) await assertBioMediaAllowed(ctx.prisma, userId, input.bio); } await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: userId }, data: { instructorBio: input.bio === null ? Prisma.DbNull : (input.bio as Prisma.InputJsonValue), ...(input.order !== undefined ? { instructorOrder: input.order } : {}), }, }); // Déclare les fichiers de la bibliothèque utilisés par la bio : sans // ce lien, ils ne sont ni protégés, ni servis aux visiteurs. await syncPageMediaReferences(tx, { pageType: "INSTRUCTOR_BIO", pageId: userId, newContent: input.bio, }); }); return { success: true }; }), /** * La page publique des instructeurs titulaires. * * PUBLIQUE : elle alimente une page ouverte à tous. Deux filtres — * 1. titulaire : le MEME critere que `getInstructors` (rattachement a * une discipline, un cours ou un stage). Reutilise, pas redefini. * 2. presente : `instructorBio` non nul, sinon on afficherait une carte * vide pour un titulaire qui ne s'est pas encore decrit. * * Tri par `instructorOrder` (les nuls en dernier), puis par nom. */ listPublicInstructors: publicProcedure.query(async ({ ctx }) => { const rows = await ctx.prisma.user.findMany({ where: { AND: [ { instructorBio: { not: Prisma.DbNull } }, { OR: [ { disciplinesAsInstructor: { some: {} } }, { coursesAsInstructor: { some: {} } }, { seminarsAsPrimaryAnimator: { some: {} } }, { seminarsAsAnimator: { some: {} } }, ], }, ], }, select: { id: true, firstName: true, lastName: true, pseudo: true, avatar: true, instructorBio: true, instructorOrder: true, }, orderBy: [ { instructorOrder: { sort: "asc", nulls: "last" } }, { lastName: "asc" }, { firstName: "asc" }, ], }); return rows; }), /** * Vivier d'AFFECTATION du select instructeur (≠ « titulaires » réels). * Résout l'œuf-et-la-poule : membres du groupe ADMIN (le staff) OU déjà * rattachés (pour ne perdre aucun instructeur existant, même non-admin). * `getInstructors` / `listPublicInstructors` restent la vérité publique. */ getAssignableInstructors: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { return ctx.prisma.user.findMany({ where: { OR: [ { memberGroupMemberships: { some: { group: { isAdminGroup: true } } } }, { disciplinesAsInstructor: { some: {} } }, { coursesAsInstructor: { some: {} } }, { seminarsAsPrimaryAnimator: { some: {} } }, { seminarsAsAnimator: { some: {} } }, // Organisateur d'un évènement : sans cette ligne, un organisateur qui n'est ni admin ni // instructeur disparaît du sélecteur, et le formulaire d'édition affiche « — Choisir — ». { eventsAsOrganizer: { some: {} } }, ], }, select: { id: true, firstName: true, lastName: true, pseudo: true, email: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); }), /** * Liste tous les users qui sont **factuellement** instructeurs — c'est-à-dire * qui ont au moins une relation d'animation dans le club : ils animent * une discipline, un cours, ou un stage (en tant qu'animateur principal * ou secondaire). * * Le club n'a pas de rôle "instructor" nominal en schéma ; cette * définition factuelle permet de peupler les sélecteurs des forms admin * (Course, Stage, etc.) sans dépendre d'un signal qui n'existe pas. * * Si un jour tu veux désigner un nouvel instructeur qui n'anime encore * rien, il faudra introduire un autre signal (rôle dédié ou flag * `isInstructor` sur User) — auquel cas cette query devra évoluer. * * Sélection minimale (id + nom) suffisante pour l'affichage en dropdown. * Tri par lastName puis firstName pour l'UX du select. */ getInstructors: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { return ctx.prisma.user.findMany({ where: { OR: [ { disciplinesAsInstructor: { some: {} } }, { coursesAsInstructor: { some: {} } }, { seminarsAsPrimaryAnimator: { some: {} } }, { seminarsAsAnimator: { some: {} } }, ], }, select: { id: true, firstName: true, lastName: true, pseudo: true, email: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); }), /** * Adresse du club d'un membre (alias @<domaine du club>), saisie par un administrateur. * Le domaine est celui de l'adresse de contact des Réglages du site (contact@akfc.fr par * défaut) : on n'attribue pas d'adresse d'un autre domaine. Chaîne vide → adresse retirée. */ setClubEmail: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string().min(1), clubEmail: z.string().trim().max(254) })) .mutation(async ({ ctx, input }) => { const value = input.clubEmail.toLowerCase(); if (value !== "") { if (!z.string().email().safeParse(value).success) { throw new TRPCError({ code: "BAD_REQUEST", message: "Adresse e-mail invalide." }); } const settings = await ctx.prisma.siteSettings.findUnique({ where: { id: "site" }, select: { contactEmail: true } }); const domain = (settings?.contactEmail ?? "contact@akfc.fr").split("@")[1]?.toLowerCase() ?? "akfc.fr"; if (!value.endsWith(`@${domain}`)) { throw new TRPCError({ code: "BAD_REQUEST", message: `L'adresse du club doit se terminer par @${domain}.` }); } } await ctx.prisma.user.update({ where: { id: input.id }, data: { clubEmail: value || null } }); return { clubEmail: value || null }; }), /** * Les avatars qu'un bloc media-text ou float-text peut montrer (« utiliser l'avatar de X »). * * Un membre ne pose que SON avatar, administrateurs compris (`assertOwnAvatars`, vérifié par * chaque enregistrement de contenu) : la liste ne contient donc que lui. Elle reste une liste * pour que le sélecteur n'ait rien à changer. Inclut un membre SANS avatar (avatar `null`) : * l'UI affiche un placeholder. `avatar` est la clé de stockage brute ; l'URL d'affichage est construite côté * client comme pour l'avatar du header. */ listAvatarCandidates: protectedProcedure.query(async ({ ctx }) => { const self = ctx.sessionClient.user.id; return ctx.prisma.user.findMany({ where: { id: self }, select: { id: true, firstName: true, lastName: true, pseudo: true, avatar: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); }),});Le mot de passe est généré par une bibliothèque éprouvée, avec des règles explicites :
export default function generateStrongPassword(): string { return generatePassword.generate({ length: 12, numbers: true, symbols: true, uppercase: true, excludeSimilarCharacters: true, strict: true });Douze caractères mêlant majuscules, minuscules, chiffres et symboles (strict garantit au moins un de chaque), sans les caractères qu’on confond à la lecture (l, 1, O, 0). Chaque membre reçoit ainsi un mot de passe robuste, qu’il connaisse ou non les règles qui rendent un mot de passe difficile à deviner. Il peut le garder, ou le changer depuis son profil.
Les appartenances créées sont en accès EDITOR : dans l’espace collaboratif d’un groupe (chapitre 5), un membre peut d’emblée déposer des fichiers. L’administrateur ramène un membre au simple accès de lecture, groupe par groupe, s’il le faut.
Un mot de passe qui ne va qu’au membre#
L’e-mail de bienvenue contient le mot de passe. Il doit donc aboutir dans la boîte du membre, et nulle part ailleurs. Or un e-mail circule : on y répond en citant son contenu, et un e-mail qui ne peut pas être remis revient à l’expéditeur sous forme d’avis de non-remise (un « rebond »), qui recopie souvent le message d’origine. Le site envoie depuis noreply@, et la boîte du club, contact@, garde la mémoire des échanges : cette mémoire ne doit jamais contenir de mot de passe.
const message: SMTPTransport.Options = { from: mailFrom(shortTitle), to, // Pas de replyTo : une réponse citerait le mot de passe. // Rebond : en-têtes seulement (jamais le corps, donc jamais le mot de passe). // (Transmis seulement si le serveur SMTP annonce l'extension DSN.) dsn: { ret: "HDRS", notify: ["FAILURE", "DELAY"], orcpt: to }, subject: `Bienvenue à l'${shortTitle} : ${WELCOME_SUBJECT_MARK}`,- Pas d’adresse de réponse. Les autres e-mails du site indiquent
contact@comme adresse de réponse. Celui-ci non : une réponse revient ànoreply@. Le texte du message le dit, et donne l’adresse du club pour écrire. - Un rebond sans le message. L’option
dsndemande au serveur d’envoi, en cas d’échec, un avis qui ne contient que les en-têtes du message (RET=HDRS, extension DSN du protocole SMTP), donc pas le mot de passe.
Le reste est confié aux filtres de la boîte noreply@, dans la messagerie du club. Les avis de non-remise sont transférés à contact@, pour que les administrateurs vérifient l’adresse du membre. Un dernier filtre supprime toute réponse dont l’objet contient « ton compte est créé » : c’est la constante WELCOME_SUBJECT_MARK, qui ne doit pas changer sans que ce filtre change aussi.
La boîte du club garde malgré tout la trace de chaque création : le site lui envoie un avis sans mot de passe (compte créé pour telle adresse, par tel administrateur, dans tels groupes ; ou bien e-mail impossible à envoyer, avec l’erreur).
Le partage des rôles est net : le site décide de ce que contient chaque message, la messagerie décide où va le courrier reçu par noreply@. Aucun des deux ne refait le travail de l’autre.
La première connexion#
Un compte naît avec à vrai : le membre n’a pas encore complété son profil. Tant que c’est le cas, il est ramené sur la page d’édition de son profil, par un petit composant client placé dans le gabarit du site public :
Il lit la session dans le store client, et ne redirige jamais depuis la page de destination : aucune boucle n’est possible. replace plutôt que push : le bouton « précédent » ne ramène pas sur la page dont le membre vient d’être redirigé.
C’est une invitation, pas une sécurité : le composant s’exécute dans le navigateur, et un membre qui le contournerait n’obtiendrait rien de plus qu’un profil incomplet. Choisir un nouveau mot de passe ne lève pas le drapeau : changer de mot de passe ne complète pas le profil.
Relire la session après une écriture#
Le store client contient la session lue au chargement de la page. Une server action modifie la base, mais pas ce store : après l’enregistrement du profil, il porterait encore à vrai, et le membre serait aussitôt ramené sur le formulaire. Le formulaire relit donc la session avant de quitter la page :
La même relecture met à jour le nom affiché dans le header.
Toute donnée de la session affichée par le client (nom, avatar, état de la première connexion) doit être rafraîchie dans le store après l’écriture qui la change. Le serveur a la bonne valeur ; c’est la copie du navigateur qui retarde.
Le profil, scellé sur son titulaire#
Le formulaire du profil (prénom, nom, pseudo, présentation, téléphone, date de naissance) est envoyé par une server action :
updateMeFormAction — un seul profil possible
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
// ⚠ Fichier introuvable : apps/web/src/features/admin/users/forms/update-me/updateMeForm.action.ts// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.satisfies Prisma.UserUpdateInput fait vérifier l’objet d’écriture par le type de Prisma sans en perdre la forme exacte : un champ mal nommé ne compile pas.
Le nom affiché#
Partout où un membre apparaît (commentaires, réactions, demandes, fichiers des espaces de groupe), son nom passe par une même fonction :
Le prénom et le nom, sinon le pseudo, sinon « Membre ». L’adresse e-mail n’est un recours que lorsque l’appelant l’a fournie, c’est-à-dire là où il a le droit de la voir : un administrateur, ou le membre lui-même sur sa propre page. Les données envoyées aux autres membres et aux visiteurs n’en contiennent jamais (partie « Fondations front », la frontière serveur / client).
Un compte qui part#
Supprimer un compte efface aussi ce qui n’existe que pour lui :
user.delete — ne rien laisser derrière
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { Prisma } from "@prisma/client";import { router, protectedProcedure, publicProcedure } from "@backend/trpc/core";import { isAdmin } from "@backend/trpc/middleware";import { z } from "zod";import generateStrongPassword from "@backend/lib/security/generatePassword";import { hashPassword } from "@backend/lib/auth/password";import { sendWelcomeEmail, sendWelcomeNotice } from "@backend/email/templates/welcomeEmailWithPassword";import { isDemoEmail } from "@backend/lib/demoAccounts";import { TRPCError } from "@trpc/server";import type { UserProfile } from "@contracts/users/user-profile.types";import type { ImageConsentProofValue } from "@contracts/legal/imageConsent";import { legalInfoInput, legalInfoData } from "@backend/modules/users/legalInfo";import { emailSchema } from "@contracts/auth/auth.schema";import { syncPageMediaReferences } from "@backend/modules/media/services/syncPageMediaReferences.service";import { pageContentSchemaV1 } from "@contracts/page";import { assertBioMediaAllowed, isInstructorUser } from "@backend/modules/users/instructorBio.service";import { removeAvatarFile } from "@backend/modules/avatar/avatar.service";import { resolvePersoBaseFolder } from "@backend/modules/media/services/resolvePersoBaseFolder.service";import { assertOwnAvatars } from "@backend/modules/media/services/assertOwnAvatars.service";import { byEffectiveDesc } from "@backend/modules/imageConsent/formOrder";/** Les champs du profil, et eux seuls : ni l'empreinte du mot de passe, ni les données légales. */const userProfileSelect = { id: true, email: true, firstName: true, lastName: true, pseudo: true, avatar: true, aboutMe: true, phone: true, birthDate: true, isFirstLogin: true,} satisfies Prisma.UserSelect;type UserProfileRow = Prisma.UserGetPayload<{ select: typeof userProfileSelect }>;/** * Du membre lu en base au contrat partagé avec le front : la date devient « AAAA-MM-JJ ». * `satisfies` vérifie la conformité à UserProfile sans élargir le type : le front garde * `id: string` (sûr), là où le contrat, plus large, admettrait `null`. */function toUserProfile(user: UserProfileRow) { return { ...user, birthDate: user.birthDate ? user.birthDate.toISOString().split("T")[0] : null, } satisfies UserProfile;}export const userRouter = router({ getAll: protectedProcedure .use(isAdmin) .query(async ({ ctx }) => { // Jamais l'empreinte du mot de passe vers le navigateur. const users = await ctx.prisma.user.findMany({ orderBy: { id: "asc" }, omit: { password: true }, include: { memberGroupMemberships: { select: { group: { select: { id: true, name: true, isAdminGroup: true } } }, }, // Droit à l'image : voie de la version la plus récente (par date des droits), et demande en // attente du parent. imageConsentForms: { select: { source: true, signedAt: true, createdAt: true } }, imageConsentRequest: { select: { expiresAt: true } }, }, }); const now = new Date(); return users.map(({ memberGroupMemberships, imageConsentForms, imageConsentRequest, ...u }) => ({ ...u, isDemo: isDemoEmail(u.email), consentProof: (imageConsentForms.sort(byEffectiveDesc)[0]?.source ?? (imageConsentRequest?.expiresAt && imageConsentRequest.expiresAt > now ? "PENDING_GUARDIAN" : "NONE")) satisfies ImageConsentProofValue, // Groupes du membre : Administrateurs d'abord, puis ordre alphabétique. groups: memberGroupMemberships .map((m) => m.group) .sort((a, b) => Number(b.isAdminGroup) - Number(a.isAdminGroup) || a.name.localeCompare(b.name, "fr")), })); }), getById: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { id: input.id }, omit: { password: true }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return user; }), getByEmail: protectedProcedure .use(isAdmin) .input(z.object({ email: emailSchema })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { email: input.email }, omit: { password: true }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return user; }), create: protectedProcedure .use(isAdmin) .input( z.object({ // Même règle que la connexion : nettoyée, en minuscules (sinon le membre ne retrouverait // pas son compte en tapant son adresse autrement). email: emailSchema, /** Un ou plusieurs groupes (au moins un). */ groupIds: z.array(z.string().min(1)).min(1, "Choisissez au moins un groupe."), /** Majorité et, pour un mineur, date de naissance et parent référent (tous obligatoires). */ legal: legalInfoInput, }), ) .mutation(async ({ ctx, input }) => { const groupIds = [...new Set(input.groupIds)]; const found = await ctx.prisma.memberGroup.count({ where: { id: { in: groupIds } } }); if (found !== groupIds.length) { throw new TRPCError({ code: "BAD_REQUEST", message: "Groupe inconnu : rechargez la page." }); } // Mot de passe généré côté serveur (jamais saisi par l'admin), robuste par // construction, puis envoyé au SEUL membre (voir welcomeEmailWithPassword.ts). // isFirstLogin=true (défaut Prisma) : le site lui propose de compléter son profil. const password = generateStrongPassword(); const hash = await hashPassword(password); if (input.legal.legalAge === "UNKNOWN") { throw new TRPCError({ code: "BAD_REQUEST", message: "Indiquez si le membre est majeur ou mineur." }); } const legal = legalInfoData(input.legal, input.email, { requireComplete: true }); // L'adresse est unique en base (@unique). Plutôt que de vérifier avant d'écrire (deux // créations simultanées passeraient toutes les deux la vérification), on écrit, et on // traduit le refus de la base. let user; try { user = await ctx.prisma.user.create({ data: { email: input.email, password: hash, ...legal, memberGroupMemberships: { create: groupIds.map((id) => ({ group: { connect: { id } }, access: "EDITOR" as const, })), }, }, omit: { password: true }, }); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { throw new TRPCError({ code: "CONFLICT", message: "Un membre utilise déjà cette adresse e-mail." }); } throw err; } // E-mail de bienvenue au membre, puis avis SANS mot de passe à la boîte du club. // Un échec d'envoi n'annule pas la création : il est journalisé et signalé au club. // Compte de démonstration : son adresse ne peut rien recevoir, pas d'e-mail. if (!isDemoEmail(input.email)) { let sendError: string | null = null; try { await sendWelcomeEmail({ to: input.email, password }); } catch (err) { sendError = err instanceof Error ? err.message : String(err); console.error("[user.create] e-mail de bienvenue non envoyé :", sendError); } const [groups, admin] = await Promise.all([ ctx.prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { name: true } }), ctx.prisma.user.findUnique({ where: { id: ctx.sessionClient.user.id }, select: { firstName: true, lastName: true, email: true }, }), ]); const adminName = admin ? [admin.firstName, admin.lastName].filter(Boolean).join(" ").trim() || admin.email : null; await sendWelcomeNotice({ kind: sendError ? "send-failed" : "sent", memberEmail: input.email, groupNames: groups.map((g) => g.name), createdBy: adminName, detail: sendError, }).catch((err) => console.error("[user.create] avis au club non envoyé :", err instanceof Error ? err.message : err), ); } return { success: true, user, }; }), /** * Majorité, date de naissance et parent référent (admins). Un mineur peut être enregistré avant * que tout soit connu (tri des comptes existants) : l'envoi au parent reste bloqué tant qu'il * manque quelque chose. Majeur : les coordonnées du parent sont effacées. */ setLegalInfo: protectedProcedure .use(isAdmin) .input(z.object({ userId: z.string().min(1), legal: legalInfoInput })) .mutation(async ({ ctx, input }) => { const u = await ctx.prisma.user.findUnique({ where: { id: input.userId }, select: { email: true } }); if (!u) throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable." }); await ctx.prisma.user.update({ where: { id: input.userId }, data: legalInfoData(input.legal, u.email, { requireComplete: false }), select: { id: true }, }); return { ok: true }; }), /** Droit à l'image (admins) : autorisation recueillie par le club pour ce membre. */ setImageConsent: protectedProcedure .use(isAdmin) .input(z.object({ userId: z.string().min(1), consent: z.enum(["UNKNOWN", "GRANTED", "REFUSED"]) })) .mutation(async ({ ctx, input }) => { await ctx.prisma.user.update({ where: { id: input.userId }, data: { imageConsent: input.consent, imageConsentAt: new Date() }, select: { id: true }, }); return { ok: true }; }), delete: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Règle métier, pas question de droit : l'administrateur a le droit de supprimer un compte, // mais pas le sien (il perdrait l'accès, et le club pourrait se retrouver sans administrateur). // L'interface désactive déjà le bouton ; c'est ici que la règle est garantie. if (input.id === ctx.sessionClient.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez pas supprimer votre propre compte." }); } const avatar = (await ctx.prisma.user.findUnique({ where: { id: input.id }, select: { avatar: true } }))?.avatar; const deleted = await ctx.prisma.$transaction(async (tx) => { // Formulaires de droit à l'image signés : gardés 5 ans comme preuve (purge nocturne). await tx.imageConsentForm.updateMany({ where: { userId: input.id }, data: { detachedAt: new Date() } }); // `pageId` n'a pas de clé étrangère : sans ce nettoyage, les liens de // la bio bloqueraient pour toujours la suppression de ses images. await syncPageMediaReferences(tx, { pageType: "INSTRUCTOR_BIO", pageId: input.id, newContent: null, }); // Seul l'identifiant repart vers le navigateur : la ligne complète contient l'empreinte // du mot de passe. return tx.user.delete({ where: { id: input.id }, select: { id: true }, }); }); // Son portrait part avec le compte : un avatar n'est rattaché qu'à lui (aucune ligne // MediaAsset ne le retient), il resterait sinon dans le stockage sans que rien ne l'atteigne. // Après la transaction : un fichier ne se restaure pas si la base annule. if (avatar) await removeAvatarFile(avatar); return deleted; }), getCurrentUserProfile: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const user = await ctx.prisma.user.findUnique({ where: { id: userId }, select: userProfileSelect, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return toUserProfile(user); }), getProfileById: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { id: input.id }, select: userProfileSelect, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "Membre introuvable.", }); } return toUserProfile(user); }), /** * L'utilisateur courant est-il titulaire, et quelle est sa bio ? * * Alimente l'éditeur du profil : la section n'apparaît que pour un * titulaire, et se pré-remplit avec ce qu'il a déjà écrit. Le critère de * titulaire est le MEME OR que `getInstructors` / `listPublicInstructors`. */ getMyInstructorState: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.sessionClient.user.id; const [user, isInstructor] = await Promise.all([ ctx.prisma.user.findUnique({ where: { id: userId }, select: { instructorBio: true, instructorOrder: true }, }), isInstructorUser(ctx.prisma, userId), ]); return { isInstructor, bio: user?.instructorBio ?? null, order: user?.instructorOrder ?? null, // Où le sélecteur de médias s'ouvre : toute la bibliothèque pour un administrateur, son // espace personnel pour un autre membre (le seul dont il puisse poser les fichiers). mediaRoot: ctx.sessionClient.user.isAdmin ? ctx.appRoot : await resolvePersoBaseFolder({ prisma: ctx.prisma, appRoot: ctx.appRoot, userId }), }; }), /** * Un instructeur enregistre SA PROPRE présentation. * * La cible est toujours `ctx.sessionClient.user.id`, jamais un id reçu en * entrée : la garde « c'est mon profil » est structurelle. Aucune * permission d'administration n'entre en jeu — on n'édite que soi. * * `bio` est un document du constructeur de pages (validé par son schéma), ou `null` * pour retirer sa présentation. L'ordre est optionnel. */ saveMyInstructorBio: protectedProcedure .input( z.object({ // Le même contrat que tout contenu du constructeur de pages : un document invalide est // refusé à l'entrée, au lieu d'être enregistré tel quel. bio: pageContentSchemaV1.nullable(), order: z.number().int().nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { assertOwnAvatars(ctx.sessionClient.user.id, input.bio); const userId = ctx.sessionClient.user.id; // Seul un titulaire a une présentation publique. Un autre membre pourrait sinon référencer des // fichiers, et les rendre servis au public, sans qu'aucune page ne les montre. if (!(await isInstructorUser(ctx.prisma, userId))) { throw new TRPCError({ code: "FORBIDDEN", message: "La présentation est réservée aux instructeurs." }); } if (!ctx.sessionClient.user.isAdmin) { // L'ordre de la page publique est une décision du club : un instructeur ne s'y place pas // lui-même en tête. if (input.order !== undefined) { throw new TRPCError({ code: "FORBIDDEN", message: "L'ordre d'affichage est fixé par un administrateur." }); } if (input.bio !== null) await assertBioMediaAllowed(ctx.prisma, userId, input.bio); } await ctx.prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: userId }, data: { instructorBio: input.bio === null ? Prisma.DbNull : (input.bio as Prisma.InputJsonValue), ...(input.order !== undefined ? { instructorOrder: input.order } : {}), }, }); // Déclare les fichiers de la bibliothèque utilisés par la bio : sans // ce lien, ils ne sont ni protégés, ni servis aux visiteurs. await syncPageMediaReferences(tx, { pageType: "INSTRUCTOR_BIO", pageId: userId, newContent: input.bio, }); }); return { success: true }; }), /** * La page publique des instructeurs titulaires. * * PUBLIQUE : elle alimente une page ouverte à tous. Deux filtres — * 1. titulaire : le MEME critere que `getInstructors` (rattachement a * une discipline, un cours ou un stage). Reutilise, pas redefini. * 2. presente : `instructorBio` non nul, sinon on afficherait une carte * vide pour un titulaire qui ne s'est pas encore decrit. * * Tri par `instructorOrder` (les nuls en dernier), puis par nom. */ listPublicInstructors: publicProcedure.query(async ({ ctx }) => { const rows = await ctx.prisma.user.findMany({ where: { AND: [ { instructorBio: { not: Prisma.DbNull } }, { OR: [ { disciplinesAsInstructor: { some: {} } }, { coursesAsInstructor: { some: {} } }, { seminarsAsPrimaryAnimator: { some: {} } }, { seminarsAsAnimator: { some: {} } }, ], }, ], }, select: { id: true, firstName: true, lastName: true, pseudo: true, avatar: true, instructorBio: true, instructorOrder: true, }, orderBy: [ { instructorOrder: { sort: "asc", nulls: "last" } }, { lastName: "asc" }, { firstName: "asc" }, ], }); return rows; }), /** * Vivier d'AFFECTATION du select instructeur (≠ « titulaires » réels). * Résout l'œuf-et-la-poule : membres du groupe ADMIN (le staff) OU déjà * rattachés (pour ne perdre aucun instructeur existant, même non-admin). * `getInstructors` / `listPublicInstructors` restent la vérité publique. */ getAssignableInstructors: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { return ctx.prisma.user.findMany({ where: { OR: [ { memberGroupMemberships: { some: { group: { isAdminGroup: true } } } }, { disciplinesAsInstructor: { some: {} } }, { coursesAsInstructor: { some: {} } }, { seminarsAsPrimaryAnimator: { some: {} } }, { seminarsAsAnimator: { some: {} } }, // Organisateur d'un évènement : sans cette ligne, un organisateur qui n'est ni admin ni // instructeur disparaît du sélecteur, et le formulaire d'édition affiche « — Choisir — ». { eventsAsOrganizer: { some: {} } }, ], }, select: { id: true, firstName: true, lastName: true, pseudo: true, email: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); }), /** * Liste tous les users qui sont **factuellement** instructeurs — c'est-à-dire * qui ont au moins une relation d'animation dans le club : ils animent * une discipline, un cours, ou un stage (en tant qu'animateur principal * ou secondaire). * * Le club n'a pas de rôle "instructor" nominal en schéma ; cette * définition factuelle permet de peupler les sélecteurs des forms admin * (Course, Stage, etc.) sans dépendre d'un signal qui n'existe pas. * * Si un jour tu veux désigner un nouvel instructeur qui n'anime encore * rien, il faudra introduire un autre signal (rôle dédié ou flag * `isInstructor` sur User) — auquel cas cette query devra évoluer. * * Sélection minimale (id + nom) suffisante pour l'affichage en dropdown. * Tri par lastName puis firstName pour l'UX du select. */ getInstructors: protectedProcedure.use(isAdmin).query(async ({ ctx }) => { return ctx.prisma.user.findMany({ where: { OR: [ { disciplinesAsInstructor: { some: {} } }, { coursesAsInstructor: { some: {} } }, { seminarsAsPrimaryAnimator: { some: {} } }, { seminarsAsAnimator: { some: {} } }, ], }, select: { id: true, firstName: true, lastName: true, pseudo: true, email: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); }), /** * Adresse du club d'un membre (alias @<domaine du club>), saisie par un administrateur. * Le domaine est celui de l'adresse de contact des Réglages du site (contact@akfc.fr par * défaut) : on n'attribue pas d'adresse d'un autre domaine. Chaîne vide → adresse retirée. */ setClubEmail: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string().min(1), clubEmail: z.string().trim().max(254) })) .mutation(async ({ ctx, input }) => { const value = input.clubEmail.toLowerCase(); if (value !== "") { if (!z.string().email().safeParse(value).success) { throw new TRPCError({ code: "BAD_REQUEST", message: "Adresse e-mail invalide." }); } const settings = await ctx.prisma.siteSettings.findUnique({ where: { id: "site" }, select: { contactEmail: true } }); const domain = (settings?.contactEmail ?? "contact@akfc.fr").split("@")[1]?.toLowerCase() ?? "akfc.fr"; if (!value.endsWith(`@${domain}`)) { throw new TRPCError({ code: "BAD_REQUEST", message: `L'adresse du club doit se terminer par @${domain}.` }); } } await ctx.prisma.user.update({ where: { id: input.id }, data: { clubEmail: value || null } }); return { clubEmail: value || null }; }), /** * Les avatars qu'un bloc media-text ou float-text peut montrer (« utiliser l'avatar de X »). * * Un membre ne pose que SON avatar, administrateurs compris (`assertOwnAvatars`, vérifié par * chaque enregistrement de contenu) : la liste ne contient donc que lui. Elle reste une liste * pour que le sélecteur n'ait rien à changer. Inclut un membre SANS avatar (avatar `null`) : * l'UI affiche un placeholder. `avatar` est la clé de stockage brute ; l'URL d'affichage est construite côté * client comme pour l'avatar du header. */ listAvatarCandidates: protectedProcedure.query(async ({ ctx }) => { const self = ctx.sessionClient.user.id; return ctx.prisma.user.findMany({ where: { id: self }, select: { id: true, firstName: true, lastName: true, pseudo: true, avatar: true, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); }),});Supprimer une ligne ne supprime pas ce qu’elle désignait ailleurs. Un fichier du stockage, une référence sans clé étrangère, une adresse dans un journal : chaque donnée personnelle a besoin de son propre chemin de suppression, sinon elle survit au compte.
Récapitulatif#
Question | Réponse du projet |
|---|---|
Qui crée les comptes ? | Un administrateur, avec au moins un groupe et la situation légale ; le compte et ses groupes sont écrits ensemble. |
Qui choisit le mot de passe ? | Le serveur : 12 caractères de quatre familles, sans caractères ambigus ; seule l’empreinte est gardée. |
Comment le mot de passe reste-t-il au membre ? | Pas d’adresse de réponse, rebond limité aux en-têtes, filtres de noreply@ ; la boîte du club reçoit un avis sans mot de passe. |
Que signifie isFirstLogin ? | Le profil n’a pas encore été enregistré ; le membre est ramené sur /profil/edit jusque-là. |
Pourquoi relire la session après une écriture ? | Le store client garde la session du chargement ; sans relecture, le membre serait ramené sur le formulaire. |
Comment le profil est-il protégé ? | La server action prend l’identifiant dans la session, jamais dans le formulaire ; une date de naissance vérifiée est verrouillée. |
Quel nom s’affiche ? | Prénom et nom, sinon pseudo, sinon « Membre » ; l’e-mail seulement pour qui a le droit de le voir. |
Que laisse un compte supprimé ? | Rien de personnel : recensement de sa présentation, portrait du stockage ; seul le formulaire de droit à l’image signé est gardé, détaché, comme preuve. |
Showing entries1to8entries
À suivre#
Le membre a son compte et son profil. Il lui manque un visage : un avatar, qui le représente à côté de son nom, et qui suit un circuit à part — ni formulaire, ni bibliothèque.
Chapitre suivant : l’avatar. Un envoi direct au stockage, vérifié par le serveur ; un fichier par membre ; une route qui ne sert que des avatars ; représenter n’est pas poser ; et un dossier que la bibliothèque ne voit pas.