Skip to content

Commit 267e49c

Browse files
improvement(workspaces): auto-add without invite if part of organization (#5132)
* feat(workspaces): auto-add without invite if part of organization * reverse feature flag hardcoding * address comments * improve ux for org invite modal
1 parent 63fdc47 commit 267e49c

24 files changed

Lines changed: 1226 additions & 151 deletions

File tree

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

Lines changed: 117 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
mockCreatePendingInvitation,
1313
mockSendInvitationEmail,
1414
mockCancelPendingInvitation,
15+
mockGrantWorkspaceAccessDirectly,
1516
} = vi.hoisted(() => ({
1617
mockDbState: {
1718
selectResults: [] as any[],
@@ -22,6 +23,7 @@ const {
2223
mockCreatePendingInvitation: vi.fn(),
2324
mockSendInvitationEmail: vi.fn(),
2425
mockCancelPendingInvitation: vi.fn(),
26+
mockGrantWorkspaceAccessDirectly: vi.fn(),
2527
}))
2628

2729
function createSelectChain() {
@@ -115,6 +117,10 @@ vi.mock('@/lib/invitations/send', () => ({
115117
cancelPendingInvitation: mockCancelPendingInvitation,
116118
}))
117119

120+
vi.mock('@/lib/invitations/direct-grant', () => ({
121+
grantWorkspaceAccessDirectly: mockGrantWorkspaceAccessDirectly,
122+
}))
123+
118124
vi.mock('@/lib/messaging/email/validation', () => ({
119125
quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })),
120126
}))
@@ -151,6 +157,7 @@ describe('POST /api/organizations/[id]/invitations', () => {
151157
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
152158
})
153159
mockSendInvitationEmail.mockResolvedValue({ success: true })
160+
mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' })
154161
})
155162

156163
it('creates a unified invitation and sends a single email', async () => {
@@ -191,15 +198,15 @@ describe('POST /api/organizations/[id]/invitations', () => {
191198
expect(mockCancelPendingInvitation).not.toHaveBeenCalled()
192199
})
193200

194-
it('sends a workspace invitation to an existing member for selected workspaces they lack', async () => {
201+
it('adds an existing member directly to selected workspaces they lack (no invitation/email)', async () => {
195202
mockGetSession.mockResolvedValue(
196203
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
197204
)
198205
mockDbState.selectResults = [
199206
[{ role: 'owner' }],
200207
[{ name: 'Org One' }],
201-
[{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }],
202-
[{ id: 'ws-2', organizationId: 'org-1', workspaceMode: 'organization' }],
208+
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
209+
[{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }],
203210
[{ userId: 'user-2', userEmail: 'member@example.com' }],
204211
[],
205212
[{ userId: 'user-2', workspaceId: 'ws-1' }],
@@ -224,30 +231,111 @@ describe('POST /api/organizations/[id]/invitations', () => {
224231
)
225232

226233
expect(response.status).toBe(200)
227-
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1)
228-
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
234+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
235+
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
236+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1)
237+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
229238
expect.objectContaining({
230-
kind: 'workspace',
239+
userId: 'user-2',
231240
email: 'member@example.com',
241+
workspaceId: 'ws-2',
242+
permission: 'write',
232243
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' }],
242244
})
243245
)
244246

245247
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.invitationsSent).toBe(0)
249+
expect(body.data.directlyAdded).toEqual(['member@example.com'])
250+
expect(body.data.directlyAddedCount).toBe(1)
248251
expect(body.data.existingMembers).toEqual([])
249252
})
250253

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+
251339
it('returns 400 when an existing member already has access to every selected workspace', async () => {
252340
mockGetSession.mockResolvedValue(
253341
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
@@ -281,14 +369,14 @@ describe('POST /api/organizations/[id]/invitations', () => {
281369
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
282370
})
283371

284-
it('invites new emails to the organization and existing members to workspaces in one batch', async () => {
372+
it('invites new emails to the organization and adds existing members to workspaces in one batch', async () => {
285373
mockGetSession.mockResolvedValue(
286374
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
287375
)
288376
mockDbState.selectResults = [
289377
[{ role: 'owner' }],
290378
[{ name: 'Org One' }],
291-
[{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }],
379+
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
292380
[{ userId: 'user-2', userEmail: 'member@example.com' }],
293381
[],
294382
[],
@@ -310,25 +398,29 @@ describe('POST /api/organizations/[id]/invitations', () => {
310398
)
311399

312400
expect(response.status).toBe(200)
313-
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2)
401+
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1)
314402
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
315403
expect.objectContaining({
316404
kind: 'organization',
317405
email: 'new@example.com',
318406
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
319407
})
320408
)
321-
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
409+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1)
410+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
322411
expect.objectContaining({
323-
kind: 'workspace',
412+
userId: 'user-2',
324413
email: 'member@example.com',
325-
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
414+
workspaceId: 'ws-1',
415+
permission: 'read',
326416
})
327417
)
328418

329419
const body = await response.json()
330-
expect(body.data.invitationsSent).toBe(2)
331-
expect(body.data.invitedEmails).toEqual(['new@example.com', 'member@example.com'])
420+
expect(body.data.invitationsSent).toBe(1)
421+
expect(body.data.invitedEmails).toEqual(['new@example.com'])
422+
expect(body.data.directlyAdded).toEqual(['member@example.com'])
423+
expect(body.data.directlyAddedCount).toBe(1)
332424
})
333425

334426
it('still rejects existing members on the non-batch organization invite path', async () => {

0 commit comments

Comments
 (0)