--- 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 @@