Skip to content

Commit 588701b

Browse files
committed
improve ux for org invite modal
1 parent 54de852 commit 588701b

5 files changed

Lines changed: 135 additions & 20 deletions

File tree

apps/sim/app/api/organizations/[id]/invitations/route.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,91 @@ describe('POST /api/organizations/[id]/invitations', () => {
251251
expect(body.data.existingMembers).toEqual([])
252252
})
253253

254+
it('reports a partially-failed member only as added, never in both buckets', async () => {
255+
mockGetSession.mockResolvedValue(
256+
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
257+
)
258+
// First grant succeeds, second throws (e.g. transient DB error).
259+
mockGrantWorkspaceAccessDirectly
260+
.mockResolvedValueOnce({ outcome: 'added', permission: 'write' })
261+
.mockRejectedValueOnce(new Error('db blip'))
262+
mockDbState.selectResults = [
263+
[{ role: 'owner' }],
264+
[{ name: 'Org One' }],
265+
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
266+
[{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }],
267+
[{ userId: 'user-2', userEmail: 'member@example.com' }],
268+
[],
269+
[],
270+
[],
271+
[{ name: 'Owner', email: 'owner@example.com' }],
272+
]
273+
274+
const response = await POST(
275+
createMockRequest(
276+
'POST',
277+
{
278+
emails: ['member@example.com'],
279+
workspaceInvitations: [
280+
{ workspaceId: 'ws-1', permission: 'write' },
281+
{ workspaceId: 'ws-2', permission: 'write' },
282+
],
283+
},
284+
{},
285+
'http://localhost/api/organizations/org-1/invitations?batch=true'
286+
),
287+
{ params: Promise.resolve({ id: 'org-1' }) }
288+
)
289+
290+
expect(response.status).toBe(200)
291+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(2)
292+
const body = await response.json()
293+
expect(body.data.directlyAdded).toEqual(['member@example.com'])
294+
expect(body.data.failedInvitations).toEqual([])
295+
})
296+
297+
it('returns 207 with both successes and failures when one member is added and another fails', async () => {
298+
mockGetSession.mockResolvedValue(
299+
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
300+
)
301+
mockGrantWorkspaceAccessDirectly
302+
.mockResolvedValueOnce({ outcome: 'added', permission: 'write' })
303+
.mockRejectedValueOnce(new Error('db blip'))
304+
mockDbState.selectResults = [
305+
[{ role: 'owner' }],
306+
[{ name: 'Org One' }],
307+
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
308+
[
309+
{ userId: 'user-a', userEmail: 'a@example.com' },
310+
{ userId: 'user-b', userEmail: 'b@example.com' },
311+
],
312+
[],
313+
[],
314+
[],
315+
[{ name: 'Owner', email: 'owner@example.com' }],
316+
]
317+
318+
const response = await POST(
319+
createMockRequest(
320+
'POST',
321+
{
322+
emails: ['a@example.com', 'b@example.com'],
323+
workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'write' }],
324+
},
325+
{},
326+
'http://localhost/api/organizations/org-1/invitations?batch=true'
327+
),
328+
{ params: Promise.resolve({ id: 'org-1' }) }
329+
)
330+
331+
expect(response.status).toBe(207)
332+
const body = await response.json()
333+
expect(body.success).toBe(false)
334+
expect(body.data.directlyAdded).toEqual(['a@example.com'])
335+
expect(body.data.directlyAddedCount).toBe(1)
336+
expect(body.data.failedInvitations).toEqual([{ email: 'b@example.com', error: 'db blip' }])
337+
})
338+
254339
it('returns 400 when an existing member already has access to every selected workspace', async () => {
255340
mockGetSession.mockResolvedValue(
256341
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })

apps/sim/app/api/organizations/[id]/invitations/route.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,8 @@ export const POST = withRouteHandler(
499499
const memberUserId = memberUserIdByEmail.get(memberInvite.email)
500500
if (!memberUserId) continue
501501

502-
let grantedAny = false
502+
let addedAny = false
503+
let lastGrantError: string | null = null
503504
for (const grant of memberInvite.grants) {
504505
try {
505506
const grantResult = await grantWorkspaceAccessDirectly({
@@ -515,20 +516,22 @@ export const POST = withRouteHandler(
515516
request,
516517
})
517518

518-
if (grantResult.outcome === 'added') grantedAny = true
519+
if (grantResult.outcome === 'added') addedAny = true
519520
} catch (grantError) {
520521
logger.error('Failed to grant workspace access directly', {
521522
email: memberInvite.email,
522523
workspaceId: grant.workspaceId,
523524
error: grantError,
524525
})
525-
failedInvitations.push({
526-
email: memberInvite.email,
527-
error: getErrorMessage(grantError, 'Failed to add member to workspace'),
528-
})
526+
lastGrantError = getErrorMessage(grantError, 'Failed to add member to workspace')
529527
}
530528
}
531-
if (grantedAny) directlyAdded.push(memberInvite.email)
529+
530+
if (addedAny) {
531+
directlyAdded.push(memberInvite.email)
532+
} else if (lastGrantError) {
533+
failedInvitations.push({ email: memberInvite.email, error: lastGrantError })
534+
}
532535
}
533536

534537
for (const inv of sentInvitations) {

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,17 @@ export function OrganizationInviteModal({
116116
onSuccess: (result) => {
117117
const summary =
118118
'data' in result && result.data && typeof result.data === 'object'
119-
? (result.data as { invitationsSent?: number; directlyAddedCount?: number })
119+
? (result.data as {
120+
invitationsSent?: number
121+
directlyAddedCount?: number
122+
failedInvitations?: Array<{ email: string; error: string }>
123+
})
120124
: null
121125
const addedCount = summary?.directlyAddedCount ?? 0
122126
const sentCount = summary?.invitationsSent ?? 0
127+
const failed = summary?.failedInvitations ?? []
128+
129+
// Surface partial successes even when some addresses fail.
123130
const parts: string[] = []
124131
if (addedCount > 0) {
125132
parts.push(`${addedCount} member${addedCount === 1 ? '' : 's'} added`)
@@ -130,6 +137,18 @@ export function OrganizationInviteModal({
130137
if (parts.length > 0) {
131138
toast.success(parts.join(' · '))
132139
}
140+
141+
if (failed.length > 0) {
142+
// Keep only the failed addresses (workspaces stay selected) for retry.
143+
setEmails(failed.map((entry) => entry.email))
144+
setErrorMessage(
145+
failed.length === 1
146+
? failed[0].error
147+
: `${failed.length} invitations failed. ${failed[0].error}`
148+
)
149+
return
150+
}
151+
133152
setEmails([])
134153
setSelectedWorkspaceIds([])
135154
onOpenChange(false)

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,6 @@ export function InviteModal({
101101
{ workspaceId, organizationId, invitations },
102102
{
103103
onSuccess: (result) => {
104-
if (result.failed.length > 0) {
105-
setEmails(result.failed.map((f) => f.email))
106-
setErrorMessage(result.failed[0].error)
107-
return
108-
}
109104
const parts: string[] = []
110105
if (result.added.length > 0) {
111106
parts.push(`${result.added.length} member${result.added.length === 1 ? '' : 's'} added`)
@@ -118,6 +113,18 @@ export function InviteModal({
118113
if (parts.length > 0) {
119114
toast.success(parts.join(' · '))
120115
}
116+
117+
if (result.failed.length > 0) {
118+
// Keep the failed addresses in the field with the error for retry.
119+
setEmails(result.failed.map((f) => f.email))
120+
setErrorMessage(
121+
result.failed.length === 1
122+
? result.failed[0].error
123+
: `${result.failed.length} invitations failed. ${result.failed[0].error}`
124+
)
125+
return
126+
}
127+
121128
setEmails([])
122129
onOpenChange(false)
123130
},

apps/sim/hooks/queries/organization.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -414,20 +414,21 @@ export function useInviteMember() {
414414

415415
return useMutation({
416416
mutationFn: async ({ emails, workspaceInvitations, orgId }: InviteMemberParams) => {
417-
const result = await requestJson(inviteOrganizationMembersContract, {
417+
/**
418+
* Partial batches return HTTP 207 with `success: false` and a `data`
419+
* payload (some invited/added, some failed). `requestJson` only throws on
420+
* >= 400 (e.g. the total-failure 502 / validation 400 paths), so partials
421+
* resolve here and the caller reports successes + per-email failures from
422+
* `data` instead of surfacing a single generic error.
423+
*/
424+
return requestJson(inviteOrganizationMembersContract, {
418425
params: { id: orgId },
419426
query: { batch: true },
420427
body: {
421428
emails,
422429
workspaceInvitations,
423430
},
424431
})
425-
426-
if (result.success === false) {
427-
throw new Error(result.error || result.message || 'Failed to invite teammate')
428-
}
429-
430-
return result
431432
},
432433
onSettled: (_data, _error, variables) => {
433434
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })

0 commit comments

Comments
 (0)