From a7ac27e53011617300bf26805f63de7fbf201d92 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 16:05:41 +0000 Subject: [PATCH] Les identifiants partent par e-mail, sans jamais passer par la file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les cinq actions qui émettent un mot de passe temporaire — création d'un adhérent, rattrapage des comptes manquants, réinitialisation, invitation staff, approbation d'une demande — l'envoient désormais au destinataire. L'envoi est **en ligne directe**, jamais mis en file : `mail_messages.html` est stocké en base, et CLAUDE.md pose que le mot de passe temporaire n'est jamais conservé. Le clair n'existe donc que dans la portée de l'action, et seule une trace sans contenu est journalisée. C'est un plus, jamais un point de rupture : `OneTimeCredentials` continue d'afficher le mot de passe quoi qu'il arrive, et annonce « envoyé à … » ou « envoi impossible, transmettez-le à la main ». Boîte non configurée, l'écran se comporte exactement comme avant. Au passage, le rattrapage des comptes manquants ne perd plus l'état d'envoi de chaque ligne : la liste propage l'objet entier au lieu d'en recopier trois champs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP --- src/app/backend/actions.ts | 96 ++++++++++++++++--- .../adherents/BackfillAccountsForm.tsx | 5 +- src/components/OneTimeCredentials.tsx | 37 ++++++- src/lib/email-templates.ts | 44 +++++++++ 4 files changed, 165 insertions(+), 17 deletions(-) diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 9840bd6..b2a0dac 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -56,7 +56,7 @@ import type { MailProvider } from "@/db/schema"; import { sendNow } from "@/lib/mailer"; import { cancelMailMessage, logSentMail, retryMailMessage } from "@/lib/mail-outbox"; import { selfRecipient } from "@/lib/mail-recipients"; -import { buildGeneralEmail } from "@/lib/email-templates"; +import { buildCredentialsEmail, buildGeneralEmail } from "@/lib/email-templates"; import { emailBrand, getSiteSettings } from "@/lib/site-settings"; import { siteUrl } from "@/lib/social-accounts"; import { markInformationsRead as markRead } from "@/lib/informations"; @@ -373,7 +373,55 @@ export async function publishPromo(formData: FormData) { * qui a déclenché l'action. Un export de la base ne peut donc plus révéler de * mot de passe utilisable. */ -export type IssuedCredentials = { email: string; tempPassword: string }; +export type IssuedCredentials = { + email: string; + tempPassword: string; + /** Faux si aucune boîte n'est configurée ou si le fournisseur a refusé. */ + mailed: boolean; + mailError?: string; +}; + +/** + * Envoie les identifiants au destinataire, tout de suite. + * + * **Jamais par la file d'attente** : `mail_messages.html` est stocké en base, + * et le mot de passe temporaire ne doit exister que le temps de l'action. Seule + * une trace sans contenu est journalisée. + * + * L'envoi est un plus, jamais un point de rupture : en cas d'échec, le mot de + * passe reste affiché à l'écran comme avant, et l'appelant en est informé. + */ +async function deliverCredentials( + input: { name: string; email: string; tempPassword: string; intro: string }, + actorId: number | null +): Promise<{ mailed: boolean; mailError?: string }> { + try { + const settings = await getSiteSettings(); + const { subject, html } = buildCredentialsEmail( + { name: input.name, email: input.email, tempPassword: input.tempPassword, intro: input.intro }, + await siteUrl(), + emailBrand(settings) + ); + const result = await sendNow({ + to: input.email, + toName: input.name, + subject, + html, + replyTo: settings.association_email || null, + }); + await logSentMail({ + kind: "credentials", + toAddress: input.email, + subject, + createdById: actorId, + result: result.ok ? { ok: true } : { ok: false, reason: result.reason }, + }); + return result.ok ? { mailed: true } : { mailed: false, mailError: result.reason }; + } catch (error) { + // Un envoi raté ne doit jamais faire échouer la création du compte. + return { mailed: false, mailError: error instanceof Error ? error.message : "Envoi impossible" }; + } +} export type CreatedMemberAccount = IssuedCredentials & { memberId: number }; @@ -412,7 +460,7 @@ async function resolveMemberTags( export async function addMember( formData: FormData ): Promise { - const { role } = await requireRole(); + const { role, userId } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); const name = String(formData.get("name") ?? "").trim(); @@ -448,10 +496,15 @@ export async function addMember( await logActivity(`Nouvel adhérent ajouté : ${name}`, "#2C6FB3"); + const delivery = await deliverCredentials( + { name, email, tempPassword, intro: "Votre compte adhérent Plein R est ouvert. Voici de quoi vous connecter à votre espace : fiche publique, promotions et informations de l'association." }, + userId + ); + revalidatePath("/backend/adherents"); revalidatePath("/backend"); revalidatePath("/"); - return { memberId: newMember.id, email, tempPassword }; + return { memberId: newMember.id, email, tempPassword, ...delivery }; } // Crée des comptes de connexion pour les adhérents existants qui n'en ont pas @@ -459,7 +512,7 @@ export async function addMember( // reçoit un mot de passe temporaire à changer à la première connexion. Les // identifiants sont renvoyés pour un affichage unique : rien n'est conservé. export async function createMissingMemberAccounts(): Promise<(IssuedCredentials & { name: string })[]> { - const { role } = await requireRole(); + const { role, userId: actorId } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); const allMembers = await db @@ -488,7 +541,11 @@ export async function createMissingMemberAccounts(): Promise<(IssuedCredentials mustChangePassword: true, }); takenEmails.add(email); - created.push({ name: m.name, email, tempPassword }); + const delivery = await deliverCredentials( + { name: m.name, email, tempPassword, intro: "Votre compte adhérent Plein R est ouvert. Voici de quoi vous connecter à votre espace : fiche publique, promotions et informations de l'association." }, + actorId + ); + created.push({ name: m.name, email, tempPassword, ...delivery }); } if (created.length > 0) { @@ -501,7 +558,7 @@ export async function createMissingMemberAccounts(): Promise<(IssuedCredentials // Réinitialise le mot de passe d'un adhérent : le nouveau mot de passe // temporaire est renvoyé pour un affichage unique, puis oublié. export async function resetMemberPassword(formData: FormData): Promise { - const { role } = await requireRole(); + const { role, userId } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); const memberId = Number(formData.get("memberId")); @@ -521,8 +578,13 @@ export async function resetMemberPassword(formData: FormData): Promise { - const { role } = await requireRole(); + const { role, userId } = await requireRole(); if (!can(role, "manageAdmins")) throw new Error("Accès refusé"); const name = String(formData.get("name") ?? "").trim(); @@ -613,9 +675,14 @@ export async function inviteAdmin( mustChangePassword: true, }); + const delivery = await deliverCredentials( + { name, email, tempPassword, intro: "Un accès à l'administration du site Plein R vient d'être ouvert à votre nom. Voici de quoi vous connecter." }, + userId + ); + await logActivity(`${name} a été invité comme ${roleLabel}`, "#2C6FB3"); revalidatePath("/backend/administrateurs"); - return { email, tempPassword }; + return { email, tempPassword, ...delivery }; } export async function removeAdmin(formData: FormData) { @@ -1149,7 +1216,7 @@ export async function updateOwnProfile(formData: FormData) { export async function approveMembershipRequest( formData: FormData ): Promise { - const { role } = await requireRole(); + const { role, userId } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); const id = Number(formData.get("id")); @@ -1200,11 +1267,16 @@ export async function approveMembershipRequest( await db.update(membershipRequests).set({ status: "approved" }).where(eq(membershipRequests.id, id)); await logActivity(`Demande approuvée : adhérent ${req.name} créé`, "#1f8a5b"); + const delivery = await deliverCredentials( + { name: req.name, email, tempPassword, intro: "Votre demande d'adhésion a été acceptée : bienvenue chez Plein R. Voici de quoi vous connecter à votre espace adhérent." }, + userId + ); + revalidatePath("/backend/demandes"); revalidatePath("/backend/adherents"); revalidatePath("/backend"); revalidatePath("/"); - return { memberId: newMember.id, email, tempPassword }; + return { memberId: newMember.id, email, tempPassword, ...delivery }; } // ---- Inbox: membership requests + contact messages ---- diff --git a/src/app/backend/adherents/BackfillAccountsForm.tsx b/src/app/backend/adherents/BackfillAccountsForm.tsx index 5eaa534..2be7576 100644 --- a/src/app/backend/adherents/BackfillAccountsForm.tsx +++ b/src/app/backend/adherents/BackfillAccountsForm.tsx @@ -13,7 +13,7 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num if (issued) { return ( ({ label: i.label, email: i.email, tempPassword: i.tempPassword }))} + items={issued} title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`} /> ); @@ -25,7 +25,8 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num setPending(true); try { const created = await createMissingMemberAccounts(); - setIssued(created.map((c) => ({ label: c.name, email: c.email, tempPassword: c.tempPassword }))); + // On propage tout : l'état d'envoi accompagne chaque ligne. + setIssued(created.map((c) => ({ ...c, label: c.name }))); router.refresh(); } finally { setPending(false); diff --git a/src/components/OneTimeCredentials.tsx b/src/components/OneTimeCredentials.tsx index aa9f866..3906559 100644 --- a/src/components/OneTimeCredentials.tsx +++ b/src/components/OneTimeCredentials.tsx @@ -7,8 +7,34 @@ * l'état de ce composant, le temps que le staff le relève. Un rechargement de * la page le fait disparaître définitivement (il reste possible de * réinitialiser le mot de passe pour en obtenir un nouveau). + * + * L'envoi par e-mail est un **plus**, jamais un remplacement : le mot de passe + * reste affiché même quand le message est parti, et surtout quand il n'a pas + * pu partir. Boîte mail non configurée, le panneau se comporte exactement + * comme avant. */ -export type IssuedCredentialsItem = { label?: string; email: string; tempPassword: string }; +export type IssuedCredentialsItem = { + label?: string; + email: string; + tempPassword: string; + mailed?: boolean; + mailError?: string; +}; + +function Delivery({ item }: { item: IssuedCredentialsItem }) { + if (item.mailed === undefined) return null; + if (item.mailed) { + return ( + ✓ Envoyé à {item.email} + ); + } + return ( + + Envoi impossible — transmettez-le à la main + {item.mailError ? ` (${item.mailError.slice(0, 120)})` : ""} + + ); +} export function OneTimeCredentials({ items, @@ -18,6 +44,8 @@ export function OneTimeCredentials({ title?: string; }) { if (items.length === 0) return null; + const allMailed = items.every((item) => item.mailed); + return (
{title}
-
+
{items.map((item) => (
{item.label && {item.label}} @@ -37,11 +65,14 @@ export function OneTimeCredentials({ Mot de passe temporaire :{" "} {item.tempPassword} +
))}
- Notez-le maintenant : il n'est affiché qu'une seule fois et n'est conservé nulle part. + {allMailed + ? "Le message est parti, mais notez-le quand même : il n'est affiché qu'une seule fois et n'est conservé nulle part." + : "Notez-le maintenant : il n'est affiché qu'une seule fois et n'est conservé nulle part."}{" "} Un changement de mot de passe sera exigé à la première connexion.
diff --git a/src/lib/email-templates.ts b/src/lib/email-templates.ts index 2964e28..f74c787 100644 --- a/src/lib/email-templates.ts +++ b/src/lib/email-templates.ts @@ -161,3 +161,47 @@ export function buildMeetingEmail(meeting: MeetingEmailData, texts: MeetingEmail Si bouton bloqué, copiez ce lien :
${esc(registrationUrl)}`; return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }), registrationUrl }; } + +export type CredentialsEmailData = { + name: string; + email: string; + tempPassword: string; + /** « Votre compte adhérent », « Votre accès à l'administration »… */ + intro: string; +}; + +/** + * Identifiants d'un compte qui vient d'être créé ou réinitialisé. + * + * Construit dans la portée de l'action qui émet le mot de passe et expédié + * aussitôt : ce message ne passe **jamais** par la file d'attente, dont le + * corps est stocké en base. + */ +export function buildCredentialsEmail(data: CredentialsEmailData, baseUrl: string, brand: EmailBrand) { + const loginUrl = `${baseUrl.replace(/\/$/, "")}/login`; + const subject = `Vos identifiants ${brand.associationName}`; + const body = ` + +
${esc(brand.associationName)}
+
Bonjour ${esc(data.name)},
+
${lines(data.intro)}
+ + + + +
+ Identifiant
${esc(data.email)} +
Mot de passe temporaire
+ ${esc(data.tempPassword)} +
+
+ + ${cta(loginUrl, "Me connecter")} + + Ce mot de passe est provisoire : il vous sera demandé d'en choisir un + nouveau dès votre première connexion. Si vous n'êtes pas à l'origine de cette demande, prévenez + l'association. + + Si le bouton ne fonctionne pas, copiez ce lien :
${esc(loginUrl)}`; + return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }) }; +}