Corrige la page blanche à la création d'un adhérent en doublon

« Application error: a server-side exception has occurred », digest 2117999835,
sur /backend/adherents : l'action levait `Error("Un compte existe déjà avec cet
e-mail.")`. Next masque le message d'une exception de server action en
production et remplace la page entière par son écran d'erreur — un e-mail déjà
pris, cas de saisie parfaitement ordinaire, mettait donc la page par terre.

Ces échecs attendus sont désormais **renvoyés** (`ActionError`) au lieu d'être
jetés, dans `addMember`, `inviteAdmin` et `approveMembershipRequest` :

- Le formulaire affiche la vraie raison, l'e-mail en cause inclus.
- `AddMemberPanel` réinjecte la saisie : React vide un formulaire non contrôlé
  après chaque action, il fallait sinon tout retaper.
- `InviteAdminForm` avait un rattrapage, mais devait deviner le motif faute de
  message ; il affiche maintenant celui de l'action. Son `catch` ne couvre plus
  que l'imprévu.
- `ApproveRequestForm` n'en avait aucun et tombait de la même façon.

Le `throw` reste réservé aux violations d'accès.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
Claude committed 2026-09-07 07:59:25 +00:00
1 parent 50b8732080
commit 503adcb20e
5 files changed
+77 -16

No files matched your search

+6
View File
@@ -164,6 +164,12 @@ même raison.
## Sécurité ## Sécurité
- **Erreurs des server actions** : un `throw` dans une action est masqué par
Next en production (message remplacé par un digest) et fait tomber la page
sur « Application error ». Les échecs **attendus** — e-mail déjà pris, champ
manquant — sont donc **renvoyés** (`ActionError = { error: string }`) et
affichés par le formulaire, qui conserve la saisie. Le `throw` reste réservé
aux violations d'accès, qui n'ont pas à s'expliquer à l'utilisateur.
- Le journal d'activité agrège des saisies de tiers, dont le formulaire de - Le journal d'activité agrège des saisies de tiers, dont le formulaire de
contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et
rendu en éléments React (`activityNodes`), jamais en HTML brut. rendu en éléments React (`activityNodes`), jamais en HTML brut.
+27 -8
View File
@@ -359,6 +359,16 @@ export type IssuedCredentials = { email: string; tempPassword: string };
export type CreatedMemberAccount = IssuedCredentials & { memberId: number }; export type CreatedMemberAccount = IssuedCredentials & { memberId: number };
/**
* Échec **attendu** d'une action : e-mail déjà pris, champ manquant…
*
* Il est renvoyé et non `throw` : une exception levée dans une server action
* est masquée par Next en production (message remplacé par un digest) et fait
* tomber la page entière sur « Application error ». Le `throw` reste réservé
* aux violations d'accès, qui ne doivent pas s'expliquer à l'utilisateur.
*/
export type ActionError = { error: string };
/** /**
* Tags à enregistrer : la saisie si elle existe, sinon les suggestions déduites * Tags à enregistrer : la saisie si elle existe, sinon les suggestions déduites
* du métier, de la commune et de la description (`autoTags`). * du métier, de la commune et de la description (`autoTags`).
@@ -381,14 +391,16 @@ async function resolveMemberTags(
}); });
} }
export async function addMember(formData: FormData): Promise<CreatedMemberAccount | undefined> { export async function addMember(
formData: FormData
): Promise<CreatedMemberAccount | ActionError | undefined> {
const { role } = await requireRole(); const { role } = 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();
if (!name) return undefined; if (!name) return undefined;
const email = String(formData.get("email") ?? "").trim().toLowerCase(); const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (!email) throw new Error("L'e-mail est requis pour créer le compte de l'adhérent."); if (!email) return { error: "L'e-mail est requis pour créer le compte de l'adhérent." };
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null; const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
const city = String(formData.get("city") ?? "").trim() || null; const city = String(formData.get("city") ?? "").trim() || null;
const status = (String(formData.get("status") ?? "pending") as "active" | "pending"); const status = (String(formData.get("status") ?? "pending") as "active" | "pending");
@@ -396,7 +408,7 @@ export async function addMember(formData: FormData): Promise<CreatedMemberAccoun
// Un seul compte par e-mail. // Un seul compte par e-mail.
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email)); const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing.length > 0) { if (existing.length > 0) {
throw new Error("Un compte existe déjà avec cet e-mail."); return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
} }
const tags = await resolveMemberTags(null, { categoryId, city, description: null }); const tags = await resolveMemberTags(null, { categoryId, city, description: null });
@@ -551,7 +563,9 @@ export async function deleteMember(formData: FormData) {
} }
// ---- Admins ---- // ---- Admins ----
export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials | undefined> { export async function inviteAdmin(
formData: FormData
): Promise<IssuedCredentials | ActionError | undefined> {
const { role } = await requireRole(); const { role } = await requireRole();
if (!can(role, "manageAdmins")) throw new Error("Accès refusé"); if (!can(role, "manageAdmins")) throw new Error("Accès refusé");
@@ -563,7 +577,7 @@ export async function inviteAdmin(formData: FormData): Promise<IssuedCredentials
const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor"; const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor";
const existing = await db.select().from(users).where(eq(users.email, email)); const existing = await db.select().from(users).where(eq(users.email, email));
if (existing.length > 0) throw new Error("Un compte existe déjà avec cet e-mail."); if (existing.length > 0) return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
// Mot de passe temporaire montré une seule fois à l'inviteur, à changer à // Mot de passe temporaire montré une seule fois à l'inviteur, à changer à
// la première connexion. Auparavant il n'était ni conservé ni affiché : // la première connexion. Auparavant il n'était ni conservé ni affiché :
@@ -1107,7 +1121,9 @@ export async function updateOwnProfile(formData: FormData) {
// Approuve une demande d'adhésion ET crée directement l'adhérent + son compte // Approuve une demande d'adhésion ET crée directement l'adhérent + son compte
// de connexion. Renvoie l'id du nouvel adhérent et ses identifiants, à // de connexion. Renvoie l'id du nouvel adhérent et ses identifiants, à
// afficher une seule fois. // afficher une seule fois.
export async function approveMembershipRequest(formData: FormData): Promise<CreatedMemberAccount | undefined> { export async function approveMembershipRequest(
formData: FormData
): Promise<CreatedMemberAccount | ActionError | undefined> {
const { role } = await requireRole(); const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé"); if (!can(role, "manageMembers")) throw new Error("Accès refusé");
@@ -1119,12 +1135,15 @@ export async function approveMembershipRequest(formData: FormData): Promise<Crea
const email = (req.email ?? "").trim().toLowerCase(); const email = (req.email ?? "").trim().toLowerCase();
if (!email) { if (!email) {
throw new Error("Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement."); return {
error:
"Cette demande n'a pas d'e-mail : impossible de créer le compte. Créez l'adhérent manuellement.",
};
} }
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email)); const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing.length > 0) { if (existing.length > 0) {
throw new Error("Un compte existe déjà avec cet e-mail."); return { error: `Un compte existe déjà avec l'e-mail ${email}.` };
} }
const [newMember] = await db const [newMember] = await db
+25 -5
View File
@@ -14,6 +14,11 @@ export function AddMemberPanel({
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [created, setCreated] = useState<CreatedMemberAccount | null>(null); const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
const [error, setError] = useState<string | null>(null);
// React vide un formulaire non contrôlé après chaque action : sur un refus,
// on réinjecte la saisie plutôt que de la faire retaper.
const [draft, setDraft] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState(0);
const router = useRouter(); const router = useRouter();
return ( return (
@@ -38,8 +43,18 @@ export function AddMemberPanel({
{open && ( {open && (
<form <form
key={attempt}
action={async (fd) => { action={async (fd) => {
setError(null);
const result = await addMember(fd); const result = await addMember(fd);
// Échec de saisie : le formulaire reste ouvert avec la raison.
if (result && "error" in result) {
setDraft(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
setAttempt((n) => n + 1);
setError(result.error);
return;
}
setDraft({});
setOpen(false); setOpen(false);
// On reste sur la page : le mot de passe temporaire n'est affiché // On reste sur la page : le mot de passe temporaire n'est affiché
// qu'ici, une seule fois, et n'est conservé nulle part. // qu'ici, une seule fois, et n'est conservé nulle part.
@@ -48,18 +63,23 @@ export function AddMemberPanel({
}} }}
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }}
> >
{error && (
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 10, padding: "10px 14px", fontSize: 13, color: "#a3372e", marginBottom: 16 }}>
{error}
</div>
)}
<div className="grid grid-2" style={{ gap: 16 }}> <div className="grid grid-2" style={{ gap: 16 }}>
<div> <div>
<label className="field-label">Nom de l&apos;adhérent</label> <label className="field-label">Nom de l&apos;adhérent</label>
<input name="name" required placeholder="ex : Au Bon Pain" className="field" /> <input name="name" required placeholder="ex : Au Bon Pain" className="field" defaultValue={draft.name ?? ""} />
</div> </div>
<div> <div>
<label className="field-label">E-mail (identifiant de connexion)</label> <label className="field-label">E-mail (identifiant de connexion)</label>
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" /> <input name="email" type="email" required placeholder="contact@exemple.fr" className="field" defaultValue={draft.email ?? ""} />
</div> </div>
<div> <div>
<label className="field-label">Catégorie</label> <label className="field-label">Catégorie</label>
<select name="categoryId" className="field" defaultValue=""> <select name="categoryId" className="field" defaultValue={draft.categoryId ?? ""}>
<option value="">—</option> <option value="">—</option>
{categories.map((c) => ( {categories.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
@@ -70,7 +90,7 @@ export function AddMemberPanel({
</div> </div>
<div> <div>
<label className="field-label">Commune</label> <label className="field-label">Commune</label>
<select name="city" className="field" defaultValue=""> <select name="city" className="field" defaultValue={draft.city ?? ""}>
<option value="">—</option> <option value="">—</option>
{BASSIN_POMPEY_COMMUNES.map((c) => ( {BASSIN_POMPEY_COMMUNES.map((c) => (
<option key={c} value={c}> <option key={c} value={c}>
@@ -81,7 +101,7 @@ export function AddMemberPanel({
</div> </div>
<div> <div>
<label className="field-label">Statut</label> <label className="field-label">Statut</label>
<select name="status" className="field" defaultValue="pending"> <select name="status" className="field" defaultValue={draft.status ?? "pending"}>
<option value="pending">En attente</option> <option value="pending">En attente</option>
<option value="active">Actif</option> <option value="active">Actif</option>
</select> </select>
@@ -25,11 +25,16 @@ export function InviteAdminForm() {
setError(null); setError(null);
try { try {
const result = await inviteAdmin(fd); const result = await inviteAdmin(fd);
if (result && "error" in result) {
setError(result.error);
return;
}
setIssued(result ?? null); setIssued(result ?? null);
router.refresh(); router.refresh();
} catch { } catch {
// Next masque le détail des erreurs serveur en production. // Filet pour l'imprévu : les échecs de saisie, eux, sont renvoyés
setError("Impossible de créer ce compte : vérifiez que l'e-mail n'est pas déjà utilisé."); // par l'action et affichés tels quels ci-dessus.
setError("Impossible de créer ce compte. Réessayez dans un instant.");
} finally { } finally {
setPending(false); setPending(false);
} }
@@ -14,6 +14,7 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"
export function ApproveRequestForm({ requestId }: { requestId: number }) { export function ApproveRequestForm({ requestId }: { requestId: number }) {
const [created, setCreated] = useState<CreatedMemberAccount | null>(null); const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
if (created) { if (created) {
@@ -33,7 +34,12 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
setPending(true); setPending(true);
try { try {
const result = await approveMembershipRequest(fd); const result = await approveMembershipRequest(fd);
if (result) { // Échec de saisie (e-mail déjà pris, demande sans e-mail) : la
// raison s'affiche à côté du bouton, la page reste en place.
if (result && "error" in result) {
setError(result.error);
} else if (result) {
setError(null);
setCreated(result); setCreated(result);
router.refresh(); router.refresh();
} }
@@ -43,6 +49,11 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
}} }}
> >
<input type="hidden" name="id" value={requestId} /> <input type="hidden" name="id" value={requestId} />
{error && (
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 9, padding: "8px 11px", fontSize: 12.5, color: "#a3372e", marginBottom: 8, maxWidth: 280, lineHeight: 1.5 }}>
{error}
</div>
)}
<button <button
type="submit" type="submit"
disabled={pending} disabled={pending}