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 }) }; +}