Skip to content

Commit 020baad

Browse files
improvement(organization): invite validation experience (#5008)
* improvement(organization): invite validation experience * address comments
1 parent 31e166f commit 020baad

13 files changed

Lines changed: 599 additions & 143 deletions

File tree

apps/sim/app/api/emails/preview/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const emailTemplates = {
4141
'workspace-invitation': () =>
4242
renderWorkspaceInvitationEmail(
4343
'John Smith',
44-
'Engineering Team',
44+
['Engineering Team'],
4545
'https://sim.ai/workspace/invite/abc123'
4646
),
4747

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

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ vi.mock('@sim/db/schema', () => ({
7979
organizationId: 'workspace.organizationId',
8080
workspaceMode: 'workspace.workspaceMode',
8181
},
82+
permissions: {
83+
entityId: 'permissions.entityId',
84+
entityType: 'permissions.entityType',
85+
userId: 'permissions.userId',
86+
},
87+
invitationWorkspaceGrant: {
88+
invitationId: 'invitationWorkspaceGrant.invitationId',
89+
workspaceId: 'invitationWorkspaceGrant.workspaceId',
90+
},
8291
}))
8392

8493
vi.mock('drizzle-orm', () => ({
@@ -182,6 +191,175 @@ describe('POST /api/organizations/[id]/invitations', () => {
182191
expect(mockCancelPendingInvitation).not.toHaveBeenCalled()
183192
})
184193

194+
it('sends a workspace invitation to an existing member for selected workspaces they lack', async () => {
195+
mockGetSession.mockResolvedValue(
196+
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
197+
)
198+
mockDbState.selectResults = [
199+
[{ role: 'owner' }],
200+
[{ name: 'Org One' }],
201+
[{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }],
202+
[{ id: 'ws-2', organizationId: 'org-1', workspaceMode: 'organization' }],
203+
[{ userId: 'user-2', userEmail: 'member@example.com' }],
204+
[],
205+
[{ userId: 'user-2', workspaceId: 'ws-1' }],
206+
[],
207+
[{ name: 'Owner', email: 'owner@example.com' }],
208+
]
209+
210+
const response = await POST(
211+
createMockRequest(
212+
'POST',
213+
{
214+
emails: ['member@example.com'],
215+
workspaceInvitations: [
216+
{ workspaceId: 'ws-1', permission: 'write' },
217+
{ workspaceId: 'ws-2', permission: 'write' },
218+
],
219+
},
220+
{},
221+
'http://localhost/api/organizations/org-1/invitations?batch=true'
222+
),
223+
{ params: Promise.resolve({ id: 'org-1' }) }
224+
)
225+
226+
expect(response.status).toBe(200)
227+
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1)
228+
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
229+
expect.objectContaining({
230+
kind: 'workspace',
231+
email: 'member@example.com',
232+
organizationId: 'org-1',
233+
membershipIntent: 'internal',
234+
grants: [{ workspaceId: 'ws-2', permission: 'write' }],
235+
})
236+
)
237+
expect(mockSendInvitationEmail).toHaveBeenCalledWith(
238+
expect.objectContaining({
239+
kind: 'workspace',
240+
email: 'member@example.com',
241+
grants: [{ workspaceId: 'ws-2', permission: 'write' }],
242+
})
243+
)
244+
245+
const body = await response.json()
246+
expect(body.data.invitationsSent).toBe(1)
247+
expect(body.data.invitedEmails).toEqual(['member@example.com'])
248+
expect(body.data.existingMembers).toEqual([])
249+
})
250+
251+
it('returns 400 when an existing member already has access to every selected workspace', async () => {
252+
mockGetSession.mockResolvedValue(
253+
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
254+
)
255+
mockDbState.selectResults = [
256+
[{ role: 'owner' }],
257+
[{ name: 'Org One' }],
258+
[{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }],
259+
[{ userId: 'user-2', userEmail: 'member@example.com' }],
260+
[],
261+
[{ userId: 'user-2', workspaceId: 'ws-1' }],
262+
[],
263+
]
264+
265+
const response = await POST(
266+
createMockRequest(
267+
'POST',
268+
{
269+
emails: ['member@example.com'],
270+
workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'write' }],
271+
},
272+
{},
273+
'http://localhost/api/organizations/org-1/invitations?batch=true'
274+
),
275+
{ params: Promise.resolve({ id: 'org-1' }) }
276+
)
277+
278+
expect(response.status).toBe(400)
279+
const body = await response.json()
280+
expect(body.error).toContain('already has access')
281+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
282+
})
283+
284+
it('invites new emails to the organization and existing members to workspaces in one batch', async () => {
285+
mockGetSession.mockResolvedValue(
286+
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
287+
)
288+
mockDbState.selectResults = [
289+
[{ role: 'owner' }],
290+
[{ name: 'Org One' }],
291+
[{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }],
292+
[{ userId: 'user-2', userEmail: 'member@example.com' }],
293+
[],
294+
[],
295+
[],
296+
[{ name: 'Owner', email: 'owner@example.com' }],
297+
]
298+
299+
const response = await POST(
300+
createMockRequest(
301+
'POST',
302+
{
303+
emails: ['new@example.com', 'member@example.com'],
304+
workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'read' }],
305+
},
306+
{},
307+
'http://localhost/api/organizations/org-1/invitations?batch=true'
308+
),
309+
{ params: Promise.resolve({ id: 'org-1' }) }
310+
)
311+
312+
expect(response.status).toBe(200)
313+
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2)
314+
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
315+
expect.objectContaining({
316+
kind: 'organization',
317+
email: 'new@example.com',
318+
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
319+
})
320+
)
321+
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
322+
expect.objectContaining({
323+
kind: 'workspace',
324+
email: 'member@example.com',
325+
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
326+
})
327+
)
328+
329+
const body = await response.json()
330+
expect(body.data.invitationsSent).toBe(2)
331+
expect(body.data.invitedEmails).toEqual(['new@example.com', 'member@example.com'])
332+
})
333+
334+
it('still rejects existing members on the non-batch organization invite path', async () => {
335+
mockGetSession.mockResolvedValue(
336+
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
337+
)
338+
mockDbState.selectResults = [
339+
[{ role: 'owner' }],
340+
[{ name: 'Org One' }],
341+
[{ userId: 'user-2', userEmail: 'member@example.com' }],
342+
[],
343+
]
344+
345+
const response = await POST(
346+
createMockRequest(
347+
'POST',
348+
{ emails: ['member@example.com'] },
349+
{},
350+
'http://localhost/api/organizations/org-1/invitations'
351+
),
352+
{ params: Promise.resolve({ id: 'org-1' }) }
353+
)
354+
355+
expect(response.status).toBe(400)
356+
const body = await response.json()
357+
expect(body.error).toBe(
358+
'Failed to send invitation. User is already a part of the organization.'
359+
)
360+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
361+
})
362+
185363
it('rolls back the pending invitation when email delivery fails', async () => {
186364
mockGetSession.mockResolvedValue(
187365
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })

0 commit comments

Comments
 (0)