Skip to content

Commit 0e4f0e7

Browse files
fix(invitations): report every reason a batch of invites failed (#6093)
* fix(invitations): report every reason a batch of invites failed 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 <noreply@anthropic.com> * fix(invitations): name the membership field for the org it acts on 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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fadff0e commit 0e4f0e7

2 files changed

Lines changed: 109 additions & 8 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ vi.mock('@/hooks/queries/workspace', () => ({
5757
},
5858
}))
5959

60-
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal/invite-modal'
60+
import {
61+
buildInviteFailureMessage,
62+
InviteModal,
63+
} from '@/app/workspace/[workspaceId]/components/invite-modal/invite-modal'
6164

6265
let container: HTMLDivElement
6366
let root: Root
@@ -159,3 +162,70 @@ describe('InviteModal organization billing isolation', () => {
159162
expect(mockUseAdminWorkspaces).toHaveBeenCalledWith('user-1', undefined, { enabled: false })
160163
})
161164
})
165+
166+
describe('buildInviteFailureMessage', () => {
167+
const notPaid = (email: string) =>
168+
`${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.`
169+
170+
it('returns the reason alone for a single failure, which already names the address', () => {
171+
expect(buildInviteFailureMessage([{ email: 'a@x.com', error: notPaid('a@x.com') }], 3)).toBe(
172+
notPaid('a@x.com')
173+
)
174+
})
175+
176+
it('names every address when a whole batch is rejected for the same cause', () => {
177+
const message = buildInviteFailureMessage(
178+
[
179+
{ email: 'a@x.com', error: notPaid('a@x.com') },
180+
{ email: 'b@x.com', error: notPaid('b@x.com') },
181+
{ email: 'c@x.com', error: notPaid('c@x.com') },
182+
],
183+
3
184+
)
185+
186+
expect(message).toContain('None of the 3 invitations could be sent.')
187+
expect(message).toContain('a@x.com')
188+
expect(message).toContain('b@x.com')
189+
expect(message).toContain('c@x.com')
190+
})
191+
192+
it('reports the denominator when only some of the batch failed', () => {
193+
const message = buildInviteFailureMessage(
194+
[
195+
{ email: 'a@x.com', error: notPaid('a@x.com') },
196+
{ email: 'b@x.com', error: 'b@x.com has already been invited to this workspace' },
197+
],
198+
5
199+
)
200+
201+
expect(message).toContain('2 of 5 invitations could not be sent.')
202+
expect(message).toContain('b@x.com has already been invited to this workspace')
203+
})
204+
205+
it('collapses identical reasons instead of repeating them', () => {
206+
const message = buildInviteFailureMessage(
207+
[
208+
{ email: 'a@x.com', error: 'Something went wrong' },
209+
{ email: 'b@x.com', error: 'Something went wrong' },
210+
],
211+
2
212+
)
213+
214+
expect(message).toBe('None of the 2 invitations could be sent. Something went wrong')
215+
})
216+
217+
it('counts the reasons it cannot list rather than dropping them silently', () => {
218+
const message = buildInviteFailureMessage(
219+
['a', 'b', 'c', 'd', 'e'].map((name) => ({
220+
email: `${name}@x.com`,
221+
error: notPaid(`${name}@x.com`),
222+
})),
223+
5
224+
)
225+
226+
expect(message).toContain('None of the 5 invitations could be sent.')
227+
expect(message).toContain('c@x.com')
228+
expect(message).not.toContain('d@x.com')
229+
expect(message).toContain('And 2 more.')
230+
})
231+
})

apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
toast,
1414
} from '@sim/emcn'
1515
import { createLogger } from '@sim/logger'
16+
import type { BatchInvitationResult } from '@/lib/api/contracts/invitations'
1617
import { useSession } from '@/lib/auth/auth-client'
1718
import { isEnterprise } from '@/lib/billing/plan-helpers'
1819
import { isBillingEnabled } from '@/lib/core/config/env-flags'
@@ -48,6 +49,40 @@ const MEMBERSHIP_HINTS: Record<Membership, string> = {
4849

4950
const EMPTY_WORKSPACE_IDS: string[] = []
5051

52+
/** Distinct failure reasons listed in full before the remainder is counted instead. */
53+
const MAX_LISTED_FAILURE_REASONS = 3
54+
55+
/**
56+
* Builds the submit error for a batch where some or all invitations failed.
57+
*
58+
* The server rejects per email and every reason names its own address, so the
59+
* reasons are listed rather than collapsed to the first one: inviting several
60+
* people who are all ineligible for the chosen membership is the common
61+
* multi-failure case, and reporting one of them hides who else needs attention.
62+
* Identical reasons are deduplicated, and beyond
63+
* {@link MAX_LISTED_FAILURE_REASONS} the tail is counted rather than listed so a
64+
* large batch cannot bury the modal in near-identical sentences.
65+
*/
66+
export function buildInviteFailureMessage(
67+
failures: BatchInvitationResult['failed'],
68+
attemptedCount: number
69+
): string {
70+
if (failures.length === 1) return failures[0].error
71+
72+
const headline =
73+
failures.length >= attemptedCount
74+
? `None of the ${failures.length} invitations could be sent.`
75+
: `${failures.length} of ${attemptedCount} invitations could not be sent.`
76+
77+
const reasons = [...new Set(failures.map((failure) => failure.error))]
78+
const listed = reasons.slice(0, MAX_LISTED_FAILURE_REASONS)
79+
const remaining = reasons.length - listed.length
80+
81+
return [headline, ...listed, remaining > 0 ? `And ${remaining} more.` : null]
82+
.filter(Boolean)
83+
.join(' ')
84+
}
85+
5186
interface InviteModalProps {
5287
open: boolean
5388
onOpenChange: (open: boolean) => void
@@ -228,13 +263,9 @@ export function InviteModal({
228263
}
229264

230265
if (result.failed.length > 0) {
231-
// Keep the failed addresses in the field, with the reason, for retry.
266+
// Keep the failed addresses in the field, with the reasons, for retry.
232267
setEmails(result.failed.map((failure) => failure.email))
233-
setErrorMessage(
234-
result.failed.length === 1
235-
? result.failed[0].error
236-
: `${result.failed.length} invitations failed. ${result.failed[0].error}`
237-
)
268+
setErrorMessage(buildInviteFailureMessage(result.failed, emails.length))
238269
return
239270
}
240271

@@ -314,7 +345,7 @@ export function InviteModal({
314345
{isOrganizationInvite && (
315346
<ChipModalField
316347
type='dropdown'
317-
title='Membership'
348+
title='Organization membership'
318349
options={membershipOptions}
319350
value={membership}
320351
placeholder='Select membership'

0 commit comments

Comments
 (0)