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>
This commit is contained in:
Vikhyath Mondreti
2026-07-30 14:40:19 -07:00
committed by GitHub
co-authored by Claude
parent fadff0e35f
commit 0e4f0e733f
2 changed files with 109 additions and 8 deletions
@@ -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.')
})
})
@@ -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<Membership, string> = {
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 && (
<ChipModalField
type='dropdown'
title='Membership'
title='Organization membership'
options={membershipOptions}
value={membership}
placeholder='Select membership'