--- a/src/features/brand/components/settings/InviteMemberModal.tsx +++ b/src/features/brand/components/settings/InviteMemberModal.tsx @@ -2,9 +2,9 @@ // access-gate-exempt: a child modal whose only entry point is `TeamPanel`'s "Add member" // button, already gated on `members.manage`. `canGrantOwner` arrives as a prop for the // same reason: calling `useAccess()` here would duplicate a decision the parent owns. -import { useId, useMemo, useState } from "react"; +import { useId, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Mail, Send } from "@/components/icons"; import { Button } from "@/components/ui/button"; @@ -21,9 +21,9 @@ /** * Invite-members modal. Accepts a pasted list of emails and POSTs one invite per email * in a loop, not batched. Partial failure is reported: it continues through every email, - * shows the last error, and only closes when at least one succeeded. + * retains failed addresses for retry, and closes only when every address succeeded. */ export function InviteMemberModal({ brandId, canGrantOwner, @@ -40,35 +40,61 @@ const [emails, setEmails] = useState(""); const [role, setRole] = useState("editor"); const [inviting, setInviting] = useState(false); const [error, setError] = useState(null); + const [partial, setPartial] = useState<{ + created: number; + failed: number; + } | null>(null); + const sendingRef = useRef(false); const errorId = useId(); const { valid, invalid } = useMemo(() => parseEmails(emails), [emails]); const formatError = invalid.length ? t("invalidEmail", { emails: invalid.join(", ") }) : null; async function send() { + if (sendingRef.current) { + return; + } if (invalid.length) { setError(t("invalidEmail", { emails: invalid.join(", ") })); return; } if (!valid.length) { return; } setInviting(true); + sendingRef.current = true; setError(null); + setPartial(null); let ok = 0; + const failed: { email: string; message: string }[] = []; for (const email of valid) { try { await inviteMember(brandId, email, role); ok++; } catch (err) { - setError(describeError(err, t("inviteFailed", { email }))); + failed.push({ + email, + message: describeError(err, t("inviteFailed", { email })), + }); } } setInviting(false); + sendingRef.current = false; + if (failed.length) { + setEmails(failed.map(({ email }) => email).join(", ")); + setError( + failed.map(({ email, message }) => `${email}: ${message}`).join("; ") + ); + setPartial({ created: ok, failed: failed.length }); + if (ok > 0) { + onInvited(); + } + return; + } if (ok > 0) { toast.success(t("sent", { count: ok })); onInvited(); onClose(); @@ -106,9 +132,13 @@ aria-label={t("emails")} aria-invalid={!!formatError} aria-describedby={formatError || error ? errorId : undefined} value={emails} - onChange={(e) => setEmails(e.target.value)} + onChange={(e) => { + setEmails(e.target.value); + setError(null); + setPartial(null); + }} onKeyDown={(e) => { if (e.key === "Enter") { void send(); } @@ -124,14 +154,24 @@