mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
4 files changed
+165
-17
No files matched your search
+84
-12
@@ -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<CreatedMemberAccount | ActionError | undefined> {
|
||||
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é : <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");
|
||||
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<IssuedCredentials | undefined> {
|
||||
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<IssuedCre
|
||||
})
|
||||
.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}`);
|
||||
return { email: u.email, tempPassword };
|
||||
return { email: u.email, tempPassword, ...delivery };
|
||||
}
|
||||
|
||||
export async function updateMember(formData: FormData) {
|
||||
@@ -588,7 +650,7 @@ export async function deleteMember(formData: FormData) {
|
||||
export async function inviteAdmin(
|
||||
formData: FormData
|
||||
): Promise<IssuedCredentials | ActionError | undefined> {
|
||||
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(`<strong>${name}</strong> 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<CreatedMemberAccount | ActionError | undefined> {
|
||||
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 <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/adherents");
|
||||
revalidatePath("/backend");
|
||||
revalidatePath("/");
|
||||
return { memberId: newMember.id, email, tempPassword };
|
||||
return { memberId: newMember.id, email, tempPassword, ...delivery };
|
||||
}
|
||||
|
||||
// ---- Inbox: membership requests + contact messages ----
|
||||
|
||||
@@ -13,7 +13,7 @@ export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: num
|
||||
if (issued) {
|
||||
return (
|
||||
<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`}
|
||||
/>
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<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({
|
||||
items,
|
||||
@@ -18,6 +44,8 @@ export function OneTimeCredentials({
|
||||
title?: string;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
const allMailed = items.every((item) => item.mailed);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
@@ -26,7 +54,7 @@ export function OneTimeCredentials({
|
||||
<div className="font-display" style={{ fontWeight: 700, fontSize: 14.5, marginBottom: 8 }}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{items.map((item) => (
|
||||
<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>}
|
||||
@@ -37,11 +65,14 @@ export function OneTimeCredentials({
|
||||
Mot de passe temporaire :{" "}
|
||||
<strong style={{ fontFamily: "monospace", fontSize: 15, userSelect: "all" }}>{item.tempPassword}</strong>
|
||||
</span>
|
||||
<Delivery item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, marginTop: 10, lineHeight: 1.5 }}>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>`;
|
||||
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 }) };
|
||||
}
|
||||
Reference in new issue
Block a user