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..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 @@ -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 } @@ -314,7 +345,7 @@ export function InviteModal({ {isOrganizationInvite && (