Les identifiants partent par e-mail, sans jamais passer par la file

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
Claude committed 2026-09-14 16:05:41 +00:00
1 parent 8d4443a1a1
commit a7ac27e530
4 files changed
+165 -17

No files matched your search

+84 -12
View File
@@ -56,7 +56,7 @@ import type { MailProvider } from "@/db/schema";
import { sendNow } from "@/lib/mailer"; import { sendNow } from "@/lib/mailer";
import { cancelMailMessage, logSentMail, retryMailMessage } from "@/lib/mail-outbox"; import { cancelMailMessage, logSentMail, retryMailMessage } from "@/lib/mail-outbox";
import { selfRecipient } from "@/lib/mail-recipients"; 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 { emailBrand, getSiteSettings } from "@/lib/site-settings";
import { siteUrl } from "@/lib/social-accounts"; import { siteUrl } from "@/lib/social-accounts";
import { markInformationsRead as markRead } from "@/lib/informations"; 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 * qui a déclenché l'action. Un export de la base ne peut donc plus révéler de
* mot de passe utilisable. * 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 }; export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
@@ -412,7 +460,7 @@ async function resolveMemberTags(
export async function addMember( export async function addMember(
formData: FormData formData: FormData
): Promise<CreatedMemberAccount | ActionError | undefined> { ): Promise<CreatedMemberAccount | ActionError | undefined> {
const { role } = await requireRole(); const { role, userId } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé"); if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const name = String(formData.get("name") ?? "").trim(); const name = String(formData.get("name") ?? "").trim();
@@ -448,10 +496,15 @@ export async function addMember(
await logActivity(`Nouvel adhérent ajouté : <strong>${name}</strong>`, "#2C6FB3"); await logActivity(`Nouvel adhérent ajouté : <strong>${name}</strong>`, "#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/adherents");
revalidatePath("/backend"); revalidatePath("/backend");
revalidatePath("/"); 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 // 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 // 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é. // identifiants sont renvoyés pour un affichage unique : rien n'est conservé.
export async function createMissingMemberAccounts(): Promise<(IssuedCredentials & { name: string })[]> { 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é"); if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const allMembers = await db const allMembers = await db
@@ -488,7 +541,11 @@ export async function createMissingMemberAccounts(): Promise<(IssuedCredentials
mustChangePassword: true, mustChangePassword: true,
}); });
takenEmails.add(email); 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) { 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 // Réinitialise le mot de passe d'un adhérent : le nouveau mot de passe
// temporaire est renvoyé pour un affichage unique, puis oublié. // temporaire est renvoyé pour un affichage unique, puis oublié.
export async function resetMemberPassword(formData: FormData): Promise<IssuedCredentials | undefined> { export async function resetMemberPassword(formData: FormData): Promise<IssuedCredentials | undefined> {
const { role } = await requireRole(); const { role, userId } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé"); if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const memberId = Number(formData.get("memberId")); const memberId = Number(formData.get("memberId"));
@@ -521,8 +578,13 @@ export async function resetMemberPassword(formData: FormData): Promise<IssuedCre
}) })
.where(eq(users.id, u.id)); .where(eq(users.id, u.id));
const delivery = await deliverCredentials(
{ name: u.name, email: u.email, tempPassword, intro: "Le mot de passe de votre compte Plein R vient d'être réinitialisé par l'association. Vos sessions ouvertes ont été fermées." },
userId
);
revalidatePath(`/backend/adherents/${memberId}`); revalidatePath(`/backend/adherents/${memberId}`);
return { email: u.email, tempPassword }; return { email: u.email, tempPassword, ...delivery };
} }
export async function updateMember(formData: FormData) { export async function updateMember(formData: FormData) {
@@ -588,7 +650,7 @@ export async function deleteMember(formData: FormData) {
export async function inviteAdmin( export async function inviteAdmin(
formData: FormData formData: FormData
): Promise<IssuedCredentials | ActionError | undefined> { ): Promise<IssuedCredentials | ActionError | undefined> {
const { role } = await requireRole(); const { role, userId } = await requireRole();
if (!can(role, "manageAdmins")) throw new Error("Accès refusé"); if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
const name = String(formData.get("name") ?? "").trim(); const name = String(formData.get("name") ?? "").trim();
@@ -613,9 +675,14 @@ export async function inviteAdmin(
mustChangePassword: true, 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(`<strong>${name}</strong> a été invité comme ${roleLabel}`, "#2C6FB3"); await logActivity(`<strong>${name}</strong> a été invité comme ${roleLabel}`, "#2C6FB3");
revalidatePath("/backend/administrateurs"); revalidatePath("/backend/administrateurs");
return { email, tempPassword }; return { email, tempPassword, ...delivery };
} }
export async function removeAdmin(formData: FormData) { export async function removeAdmin(formData: FormData) {
@@ -1149,7 +1216,7 @@ export async function updateOwnProfile(formData: FormData) {
export async function approveMembershipRequest( export async function approveMembershipRequest(
formData: FormData formData: FormData
): Promise<CreatedMemberAccount | ActionError | undefined> { ): Promise<CreatedMemberAccount | ActionError | undefined> {
const { role } = await requireRole(); const { role, userId } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé"); if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const id = Number(formData.get("id")); 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 db.update(membershipRequests).set({ status: "approved" }).where(eq(membershipRequests.id, id));
await logActivity(`Demande approuvée : adhérent <strong>${req.name}</strong> créé`, "#1f8a5b"); await logActivity(`Demande approuvée : adhérent <strong>${req.name}</strong> 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/demandes");
revalidatePath("/backend/adherents"); revalidatePath("/backend/adherents");
revalidatePath("/backend"); revalidatePath("/backend");
revalidatePath("/"); revalidatePath("/");
return { memberId: newMember.id, email, tempPassword }; return { memberId: newMember.id, email, tempPassword, ...delivery };
} }
// ---- Inbox: membership requests + contact messages ---- // ---- Inbox: membership requests + contact messages ----
@@ -13,7 +13,7 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num
if (issued) { if (issued) {
return ( return (
<OneTimeCredentials <OneTimeCredentials
items={issued.map((i) => ({ label: i.label, email: i.email, tempPassword: i.tempPassword }))} items={issued}
title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`} title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`}
/> />
); );
@@ -25,7 +25,8 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num
setPending(true); setPending(true);
try { try {
const created = await createMissingMemberAccounts(); 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(); router.refresh();
} finally { } finally {
setPending(false); setPending(false);
+34 -3
View File
@@ -7,8 +7,34 @@
* l'état de ce composant, le temps que le staff le relève. Un rechargement de * 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 * la page le fait disparaître définitivement (il reste possible de
* réinitialiser le mot de passe pour en obtenir un nouveau). * 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 (
<span style={{ color: "#1f8a5b", fontWeight: 700 }}>✓ Envoyé à {item.email}</span>
);
}
return (
<span style={{ color: "#a8503c", fontWeight: 700 }}>
Envoi impossible — transmettez-le à la main
{item.mailError ? ` (${item.mailError.slice(0, 120)})` : ""}
</span>
);
}
export function OneTimeCredentials({ export function OneTimeCredentials({
items, items,
@@ -18,6 +44,8 @@ export function OneTimeCredentials({
title?: string; title?: string;
}) { }) {
if (items.length === 0) return null; if (items.length === 0) return null;
const allMailed = items.every((item) => item.mailed);
return ( return (
<div <div
role="status" role="status"
@@ -26,7 +54,7 @@ export function OneTimeCredentials({
<div className="font-display" style={{ fontWeight: 700, fontSize: 14.5, marginBottom: 8 }}> <div className="font-display" style={{ fontWeight: 700, fontSize: 14.5, marginBottom: 8 }}>
{title} {title}
</div> </div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{items.map((item) => ( {items.map((item) => (
<div key={item.email} style={{ display: "flex", flexWrap: "wrap", gap: "4px 14px", alignItems: "baseline" }}> <div key={item.email} style={{ display: "flex", flexWrap: "wrap", gap: "4px 14px", alignItems: "baseline" }}>
{item.label && <span style={{ fontWeight: 700, color: "#6f4b23" }}>{item.label}</span>} {item.label && <span style={{ fontWeight: 700, color: "#6f4b23" }}>{item.label}</span>}
@@ -37,11 +65,14 @@ export function OneTimeCredentials({
Mot de passe temporaire :{" "} Mot de passe temporaire :{" "}
<strong style={{ fontFamily: "monospace", fontSize: 15, userSelect: "all" }}>{item.tempPassword}</strong> <strong style={{ fontFamily: "monospace", fontSize: 15, userSelect: "all" }}>{item.tempPassword}</strong>
</span> </span>
<Delivery item={item} />
</div> </div>
))} ))}
</div> </div>
<div style={{ fontSize: 12, marginTop: 10, lineHeight: 1.5 }}> <div style={{ fontSize: 12, marginTop: 10, lineHeight: 1.5 }}>
Notez-le maintenant : il n&apos;est affiché qu&apos;une seule fois et n&apos;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. Un changement de mot de passe sera exigé à la première connexion.
</div> </div>
</div> </div>
+44
View File
@@ -161,3 +161,47 @@ export function buildMeetingEmail(meeting: MeetingEmailData, texts: MeetingEmail
<tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si bouton bloqué, copiez ce lien :<br><a href="${esc(registrationUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(registrationUrl)}</a></td></tr>`; <tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si bouton bloqué, copiez ce lien :<br><a href="${esc(registrationUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(registrationUrl)}</a></td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }), 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 = `
<tr><td style="padding:42px 40px 16px;">
<div style="padding-bottom:15px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;letter-spacing:2.2px;text-transform:uppercase;color:#9A6638;font-weight:bold;">${esc(brand.associationName)}</div>
<div style="padding-bottom:23px;font-family:Georgia,'Times New Roman',serif;font-size:30px;line-height:36px;color:#26201A;">Bonjour ${esc(data.name)},</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:25px;color:#5D5447;">${lines(data.intro)}</div>
</td></tr>
<tr><td style="padding:8px 40px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FAF7EF" style="width:100%;background:#FAF7EF;border:1px solid #E6DCC6;border-left:5px solid #E0A63C;border-collapse:collapse;">
<tr><td style="padding:24px 28px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:24px;color:#5D5447;">
Identifiant<br><strong style="font-size:16px;color:#26201A;">${esc(data.email)}</strong>
<div style="padding-top:14px;">Mot de passe temporaire<br>
<strong style="font-family:'Courier New',Courier,monospace;font-size:19px;letter-spacing:1.5px;color:#26201A;">${esc(data.tempPassword)}</strong>
</div>
</td></tr>
</table>
</td></tr>
<tr><td align="center" style="padding:26px 40px 10px;">${cta(loginUrl, "Me connecter")}</td></tr>
<tr><td style="padding:12px 40px 40px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:23px;color:#5D5447;">
Ce mot de passe est <strong style="color:#26201A;">provisoire</strong> : 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.
</td></tr>
<tr><td style="padding:0 40px 24px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:17px;color:#8C8068;">Si le bouton ne fonctionne pas, copiez ce lien :<br><a href="${esc(loginUrl)}" style="color:#2C6FB3;word-break:break-all;">${esc(loginUrl)}</a></td></tr>`;
return { subject, html: emailShell({ title: subject, content: body, baseUrl, brand }) };
}