From 13ee6a9dce722639a2bfaf7c443b9b50e8a62b6f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 29 Jul 2026 21:34:38 -0700 Subject: [PATCH 1/2] fix(invitations): report every reason a batch of invites failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite modal collapsed a multi-failure batch to `N invitations failed.` plus the first reason and discarded the rest. The batch endpoint rejects per email and each reason names its own address, so inviting several people who are all ineligible for the chosen membership — the common multi-failure case, since External requires every invitee to already be on a paid plan — named one of them and hid the others. The failed addresses are reseeded into the field as ordinary chips carrying no error state, so nothing else on screen identified them either. `buildInviteFailureMessage` now lists the distinct reasons, collapses identical ones, and reports the denominator ("2 of 5 invitations could not be sent") the way add-people-modal already did for the same partial -failure shape. Past three reasons the tail is counted rather than listed, so a large batch cannot bury the modal in near-identical sentences. Co-Authored-By: Claude --- .../invite-modal/invite-modal.test.tsx | 72 ++++++++++++++++++- .../components/invite-modal/invite-modal.tsx | 43 +++++++++-- 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx index 30dfb22fb17..5bf9becae94 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx @@ -57,7 +57,10 @@ vi.mock('@/hooks/queries/workspace', () => ({ }, })) -import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal/invite-modal' +import { + buildInviteFailureMessage, + InviteModal, +} from '@/app/workspace/[workspaceId]/components/invite-modal/invite-modal' let container: HTMLDivElement let root: Root @@ -159,3 +162,70 @@ describe('InviteModal organization billing isolation', () => { expect(mockUseAdminWorkspaces).toHaveBeenCalledWith('user-1', undefined, { enabled: false }) }) }) + +describe('buildInviteFailureMessage', () => { + const notPaid = (email: string) => + `${email} is not on a paid Sim plan, so they cannot be added as an external collaborator. Invite them as a Member or Admin instead — that adds a seat.` + + it('returns the reason alone for a single failure, which already names the address', () => { + expect(buildInviteFailureMessage([{ email: 'a@x.com', error: notPaid('a@x.com') }], 3)).toBe( + notPaid('a@x.com') + ) + }) + + it('names every address when a whole batch is rejected for the same cause', () => { + const message = buildInviteFailureMessage( + [ + { email: 'a@x.com', error: notPaid('a@x.com') }, + { email: 'b@x.com', error: notPaid('b@x.com') }, + { email: 'c@x.com', error: notPaid('c@x.com') }, + ], + 3 + ) + + expect(message).toContain('None of the 3 invitations could be sent.') + expect(message).toContain('a@x.com') + expect(message).toContain('b@x.com') + expect(message).toContain('c@x.com') + }) + + it('reports the denominator when only some of the batch failed', () => { + const message = buildInviteFailureMessage( + [ + { email: 'a@x.com', error: notPaid('a@x.com') }, + { email: 'b@x.com', error: 'b@x.com has already been invited to this workspace' }, + ], + 5 + ) + + expect(message).toContain('2 of 5 invitations could not be sent.') + expect(message).toContain('b@x.com has already been invited to this workspace') + }) + + it('collapses identical reasons instead of repeating them', () => { + const message = buildInviteFailureMessage( + [ + { email: 'a@x.com', error: 'Something went wrong' }, + { email: 'b@x.com', error: 'Something went wrong' }, + ], + 2 + ) + + expect(message).toBe('None of the 2 invitations could be sent. Something went wrong') + }) + + it('counts the reasons it cannot list rather than dropping them silently', () => { + const message = buildInviteFailureMessage( + ['a', 'b', 'c', 'd', 'e'].map((name) => ({ + email: `${name}@x.com`, + error: notPaid(`${name}@x.com`), + })), + 5 + ) + + expect(message).toContain('None of the 5 invitations could be sent.') + expect(message).toContain('c@x.com') + expect(message).not.toContain('d@x.com') + expect(message).toContain('And 2 more.') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx index b3aa448ede2..626ceac9292 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx @@ -13,6 +13,7 @@ import { toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' +import type { BatchInvitationResult } from '@/lib/api/contracts/invitations' import { useSession } from '@/lib/auth/auth-client' import { isEnterprise } from '@/lib/billing/plan-helpers' import { isBillingEnabled } from '@/lib/core/config/env-flags' @@ -48,6 +49,40 @@ const MEMBERSHIP_HINTS: Record = { const EMPTY_WORKSPACE_IDS: string[] = [] +/** Distinct failure reasons listed in full before the remainder is counted instead. */ +const MAX_LISTED_FAILURE_REASONS = 3 + +/** + * Builds the submit error for a batch where some or all invitations failed. + * + * The server rejects per email and every reason names its own address, so the + * reasons are listed rather than collapsed to the first one: inviting several + * people who are all ineligible for the chosen membership is the common + * multi-failure case, and reporting one of them hides who else needs attention. + * Identical reasons are deduplicated, and beyond + * {@link MAX_LISTED_FAILURE_REASONS} the tail is counted rather than listed so a + * large batch cannot bury the modal in near-identical sentences. + */ +export function buildInviteFailureMessage( + failures: BatchInvitationResult['failed'], + attemptedCount: number +): string { + if (failures.length === 1) return failures[0].error + + const headline = + failures.length >= attemptedCount + ? `None of the ${failures.length} invitations could be sent.` + : `${failures.length} of ${attemptedCount} invitations could not be sent.` + + const reasons = [...new Set(failures.map((failure) => failure.error))] + const listed = reasons.slice(0, MAX_LISTED_FAILURE_REASONS) + const remaining = reasons.length - listed.length + + return [headline, ...listed, remaining > 0 ? `And ${remaining} more.` : null] + .filter(Boolean) + .join(' ') +} + interface InviteModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -228,13 +263,9 @@ export function InviteModal({ } if (result.failed.length > 0) { - // Keep the failed addresses in the field, with the reason, for retry. + // Keep the failed addresses in the field, with the reasons, for retry. setEmails(result.failed.map((failure) => failure.email)) - setErrorMessage( - result.failed.length === 1 - ? result.failed[0].error - : `${result.failed.length} invitations failed. ${result.failed[0].error}` - ) + setErrorMessage(buildInviteFailureMessage(result.failed, emails.length)) return } From d723bd42a729e9a511032f24b618e51743d18584 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 30 Jul 2026 14:35:34 -0700 Subject: [PATCH 2/2] fix(invitations): name the membership field for the org it acts on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropdown grants organization standing — Member and Admin join the org and take a seat, External does not — but the label said only "Membership", which reads as membership of the workspace being invited to, the thing the field directly above it already controls. The EE access-control modal has its own "Membership" field for permission-group membership, so the bare word was ambiguous across surfaces too. Sentence case matches the sibling "Workspace access" in the same form. Co-Authored-By: Claude --- .../[workspaceId]/components/invite-modal/invite-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx index 626ceac9292..e80ebb4033a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx @@ -345,7 +345,7 @@ export function InviteModal({ {isOrganizationInvite && (