Skip to content

Commit bd40384

Browse files
committed
fix edge case with org join after invite
1 parent 825328f commit bd40384

3 files changed

Lines changed: 134 additions & 15 deletions

File tree

apps/sim/lib/billing/organizations/membership.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ export interface AddMemberResult {
237237
success: boolean
238238
memberId?: string
239239
error?: string
240+
failureCode?: MembershipAdditionFailureCode
240241
billingActions: {
241242
proUsageSnapshotted: boolean
242243
/**
@@ -282,6 +283,13 @@ export interface RemoveExternalWorkspaceAccessResult {
282283
pendingInvitationsCancelled: number
283284
}
284285

286+
export type MembershipAdditionFailureCode =
287+
| 'user-not-found'
288+
| 'organization-not-found'
289+
| 'already-member'
290+
| 'already-in-other-organization'
291+
| 'no-seats-available'
292+
285293
async function reassignOwnedOrganizationWorkspacesTx({
286294
tx,
287295
userId,
@@ -455,6 +463,7 @@ async function revokeWorkspaceCredentialMembershipsTx({
455463
export interface MembershipValidationResult {
456464
canAdd: boolean
457465
reason?: string
466+
failureCode?: MembershipAdditionFailureCode
458467
existingOrgId?: string
459468
seatValidation?: {
460469
currentSeats: number
@@ -485,6 +494,7 @@ export async function ensureUserInOrganization(
485494
success: false,
486495
alreadyMember: false,
487496
existingOrgId: existingMembership.organizationId,
497+
failureCode: 'already-in-other-organization',
488498
error:
489499
'User is already a member of another organization. Users can only belong to one organization at a time.',
490500
billingActions: {
@@ -514,7 +524,7 @@ export async function validateMembershipAddition(
514524
const [userData] = await db.select({ id: user.id }).from(user).where(eq(user.id, userId)).limit(1)
515525

516526
if (!userData) {
517-
return { canAdd: false, reason: 'User not found' }
527+
return { canAdd: false, reason: 'User not found', failureCode: 'user-not-found' }
518528
}
519529

520530
const [orgData] = await db
@@ -524,7 +534,11 @@ export async function validateMembershipAddition(
524534
.limit(1)
525535

526536
if (!orgData) {
527-
return { canAdd: false, reason: 'Organization not found' }
537+
return {
538+
canAdd: false,
539+
reason: 'Organization not found',
540+
failureCode: 'organization-not-found',
541+
}
528542
}
529543

530544
const existingMemberships = await db
@@ -538,13 +552,18 @@ export async function validateMembershipAddition(
538552
)
539553

540554
if (isAlreadyMemberOfThisOrg) {
541-
return { canAdd: false, reason: 'User is already a member of this organization' }
555+
return {
556+
canAdd: false,
557+
reason: 'User is already a member of this organization',
558+
failureCode: 'already-member',
559+
}
542560
}
543561

544562
return {
545563
canAdd: false,
546564
reason:
547565
'User is already a member of another organization. Users can only belong to one organization at a time.',
566+
failureCode: 'already-in-other-organization',
548567
existingOrgId: existingMemberships[0].organizationId,
549568
}
550569
}
@@ -556,6 +575,7 @@ export async function validateMembershipAddition(
556575
return {
557576
canAdd: false,
558577
reason: seatValidation.reason || 'No seats available',
578+
failureCode: 'no-seats-available',
559579
seatValidation: {
560580
currentSeats: seatValidation.currentSeats,
561581
maxSeats: seatValidation.maxSeats,
@@ -757,7 +777,12 @@ export async function addUserToOrganization(params: AddMemberParams): Promise<Ad
757777
acceptingInvitationId,
758778
})
759779
if (!validation.canAdd) {
760-
return { success: false, error: validation.reason, billingActions }
780+
return {
781+
success: false,
782+
error: validation.reason,
783+
failureCode: validation.failureCode,
784+
billingActions,
785+
}
761786
}
762787
} else {
763788
const existingMemberships = await db
@@ -774,6 +799,7 @@ export async function addUserToOrganization(params: AddMemberParams): Promise<Ad
774799
return {
775800
success: false,
776801
error: 'User is already a member of this organization',
802+
failureCode: 'already-member',
777803
billingActions,
778804
}
779805
}
@@ -782,6 +808,7 @@ export async function addUserToOrganization(params: AddMemberParams): Promise<Ad
782808
success: false,
783809
error:
784810
'User is already a member of another organization. Users can only belong to one organization at a time.',
811+
failureCode: 'already-in-other-organization',
785812
billingActions,
786813
}
787814
}

apps/sim/lib/invitations/core.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,86 @@ describe('acceptInvitation', () => {
124124
})
125125
)
126126
})
127+
128+
it('falls back to external access when an internal workspace invitee joined another organization', async () => {
129+
mockEnsureUserInOrganization.mockResolvedValueOnce({
130+
success: false,
131+
alreadyMember: false,
132+
existingOrgId: 'org-2',
133+
error:
134+
'User is already a member of another organization. Users can only belong to one organization at a time.',
135+
billingActions: {
136+
proUsageSnapshotted: false,
137+
proCancelledAtPeriodEnd: false,
138+
},
139+
})
140+
141+
queueWhereResponses([
142+
[
143+
{
144+
id: 'inv-1',
145+
kind: 'workspace',
146+
email: 'invitee@example.com',
147+
organizationId: 'org-1',
148+
membershipIntent: 'internal',
149+
inviterId: 'inviter-1',
150+
role: 'member',
151+
status: 'pending',
152+
token: 'tok-1',
153+
expiresAt: new Date(Date.now() + 60_000),
154+
createdAt: new Date(),
155+
updatedAt: new Date(),
156+
},
157+
],
158+
[
159+
{
160+
id: 'grant-1',
161+
workspaceId: 'workspace-1',
162+
permission: 'read',
163+
workspaceName: 'Workspace',
164+
},
165+
],
166+
[{ name: 'Acme' }],
167+
[{ name: 'Inviter', email: 'inviter@example.com' }],
168+
[],
169+
[],
170+
[{ variables: {} }],
171+
])
172+
173+
const result = await acceptInvitation({
174+
userId: 'invitee-user',
175+
userEmail: 'invitee@example.com',
176+
invitationId: 'inv-1',
177+
token: 'tok-1',
178+
})
179+
180+
expect(result.success).toBe(true)
181+
if (result.success) {
182+
expect(result.invitation.membershipIntent).toBe('external')
183+
expect(result.acceptedWorkspaceIds).toEqual(['workspace-1'])
184+
expect(result.membershipAlreadyExists).toBe(false)
185+
}
186+
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith(
187+
expect.objectContaining({
188+
userId: 'invitee-user',
189+
organizationId: 'org-1',
190+
acceptingInvitationId: 'inv-1',
191+
})
192+
)
193+
expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled()
194+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
195+
expect.objectContaining({
196+
status: 'accepted',
197+
membershipIntent: 'external',
198+
})
199+
)
200+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
201+
expect.objectContaining({
202+
userId: 'invitee-user',
203+
entityType: 'workspace',
204+
entityId: 'workspace-1',
205+
permissionType: 'read',
206+
})
207+
)
208+
})
127209
})

apps/sim/lib/invitations/core.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,8 @@ export async function acceptInvitation(
257257
}
258258

259259
let membershipAlreadyExists = false
260-
const shouldJoinOrganization = Boolean(inv.organizationId && inv.membershipIntent !== 'external')
260+
let acceptedMembershipIntent = inv.membershipIntent
261+
let shouldJoinOrganization = Boolean(inv.organizationId && inv.membershipIntent !== 'external')
261262

262263
if (shouldJoinOrganization && inv.organizationId) {
263264
const membershipResult = await ensureUserInOrganization({
@@ -269,16 +270,21 @@ export async function acceptInvitation(
269270

270271
if (!membershipResult.success) {
271272
if (membershipResult.existingOrgId) {
272-
await db
273-
.update(invitation)
274-
.set({ status: 'rejected', updatedAt: new Date() })
275-
.where(eq(invitation.id, inv.id))
276-
return { success: false, kind: 'already-in-organization' }
277-
}
278-
if (membershipResult.error?.toLowerCase().includes('no available seats')) {
273+
if (inv.kind === 'workspace' && inv.grants.length > 0) {
274+
acceptedMembershipIntent = 'external'
275+
shouldJoinOrganization = false
276+
} else {
277+
await db
278+
.update(invitation)
279+
.set({ status: 'rejected', updatedAt: new Date() })
280+
.where(eq(invitation.id, inv.id))
281+
return { success: false, kind: 'already-in-organization' }
282+
}
283+
} else if (membershipResult.failureCode === 'no-seats-available') {
279284
return { success: false, kind: 'no-seats-available' }
285+
} else {
286+
return { success: false, kind: 'server-error', message: membershipResult.error }
280287
}
281-
return { success: false, kind: 'server-error', message: membershipResult.error }
282288
}
283289

284290
membershipAlreadyExists = membershipResult.alreadyMember
@@ -289,7 +295,11 @@ export async function acceptInvitation(
289295
await db.transaction(async (tx) => {
290296
await tx
291297
.update(invitation)
292-
.set({ status: 'accepted', updatedAt: new Date() })
298+
.set({
299+
status: 'accepted',
300+
membershipIntent: acceptedMembershipIntent,
301+
updatedAt: new Date(),
302+
})
293303
.where(eq(invitation.id, inv.id))
294304

295305
for (const grant of inv.grants) {
@@ -393,7 +403,7 @@ export async function acceptInvitation(
393403

394404
return {
395405
success: true,
396-
invitation: { ...inv, status: 'accepted' },
406+
invitation: { ...inv, status: 'accepted', membershipIntent: acceptedMembershipIntent },
397407
acceptedWorkspaceIds,
398408
redirectPath,
399409
membershipAlreadyExists,

0 commit comments

Comments
 (0)