diff --git a/CLAUDE.md b/CLAUDE.md index 00795b5..39da4a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,6 +164,12 @@ même raison. ## 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 contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et rendu en éléments React (`activityNodes`), jamais en HTML brut. diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 3685f4f..96f5e49 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -359,6 +359,16 @@ export type IssuedCredentials = { email: string; tempPassword: string }; 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 * 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 { +export async function addMember( + formData: FormData +): Promise { const { role } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); const name = String(formData.get("name") ?? "").trim(); if (!name) return undefined; 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 city = String(formData.get("city") ?? "").trim() || null; const status = (String(formData.get("status") ?? "pending") as "active" | "pending"); @@ -396,7 +408,7 @@ export async function addMember(formData: FormData): Promise 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 }); @@ -551,7 +563,9 @@ export async function deleteMember(formData: FormData) { } // ---- Admins ---- -export async function inviteAdmin(formData: FormData): Promise { +export async function inviteAdmin( + formData: FormData +): Promise { const { role } = await requireRole(); if (!can(role, "manageAdmins")) throw new Error("Accès refusé"); @@ -563,7 +577,7 @@ export async function inviteAdmin(formData: FormData): Promise 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 à // 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 // de connexion. Renvoie l'id du nouvel adhérent et ses identifiants, à // afficher une seule fois. -export async function approveMembershipRequest(formData: FormData): Promise { +export async function approveMembershipRequest( + formData: FormData +): Promise { const { role } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); @@ -1119,12 +1135,15 @@ export async function approveMembershipRequest(formData: FormData): Promise 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 diff --git a/src/app/backend/adherents/AddMemberPanel.tsx b/src/app/backend/adherents/AddMemberPanel.tsx index 4e0ad8c..63b8ee3 100644 --- a/src/app/backend/adherents/AddMemberPanel.tsx +++ b/src/app/backend/adherents/AddMemberPanel.tsx @@ -14,6 +14,11 @@ export function AddMemberPanel({ }) { const [open, setOpen] = useState(false); const [created, setCreated] = useState(null); + const [error, setError] = useState(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>({}); + const [attempt, setAttempt] = useState(0); const router = useRouter(); return ( @@ -38,8 +43,18 @@ export function AddMemberPanel({ {open && (
{ + setError(null); 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); // On reste sur la page : le mot de passe temporaire n'est affiché // 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 }} > + {error && ( +
+ {error} +
+ )}
- +
- +
- {categories.map((c) => (
- {BASSIN_POMPEY_COMMUNES.map((c) => (
- diff --git a/src/app/backend/administrateurs/InviteAdminForm.tsx b/src/app/backend/administrateurs/InviteAdminForm.tsx index 259bf7b..85a929e 100644 --- a/src/app/backend/administrateurs/InviteAdminForm.tsx +++ b/src/app/backend/administrateurs/InviteAdminForm.tsx @@ -25,11 +25,16 @@ export function InviteAdminForm() { setError(null); try { const result = await inviteAdmin(fd); + if (result && "error" in result) { + setError(result.error); + return; + } setIssued(result ?? null); router.refresh(); } catch { - // Next masque le détail des erreurs serveur en production. - setError("Impossible de créer ce compte : vérifiez que l'e-mail n'est pas déjà utilisé."); + // Filet pour l'imprévu : les échecs de saisie, eux, sont renvoyés + // par l'action et affichés tels quels ci-dessus. + setError("Impossible de créer ce compte. Réessayez dans un instant."); } finally { setPending(false); } diff --git a/src/app/backend/demandes/ApproveRequestForm.tsx b/src/app/backend/demandes/ApproveRequestForm.tsx index 5ece205..0d00ce7 100644 --- a/src/app/backend/demandes/ApproveRequestForm.tsx +++ b/src/app/backend/demandes/ApproveRequestForm.tsx @@ -14,6 +14,7 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions" export function ApproveRequestForm({ requestId }: { requestId: number }) { const [created, setCreated] = useState(null); const [pending, setPending] = useState(false); + const [error, setError] = useState(null); const router = useRouter(); if (created) { @@ -33,7 +34,12 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) { setPending(true); try { 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); router.refresh(); } @@ -43,6 +49,11 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) { }} > + {error && ( +
+ {error} +
+ )}