Skip to content

Commit 07f4508

Browse files
committed
feat(governance): external workspace users from outside org
1 parent 74946fb commit 07f4508

29 files changed

Lines changed: 15602 additions & 61 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export const POST = withRouteHandler(
146146
targetEmail: inv.email,
147147
targetRole: inv.role,
148148
kind: inv.kind,
149+
membershipIntent: inv.membershipIntent,
149150
},
150151
request,
151152
})

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const GET = withRouteHandler(
5454
email: inv.email,
5555
organizationId: inv.organizationId,
5656
organizationName: inv.organizationName,
57+
membershipIntent: inv.membershipIntent,
5758
role: inv.role,
5859
status: inv.status,
5960
expiresAt: inv.expiresAt,
@@ -121,6 +122,12 @@ export const PATCH = withRouteHandler(
121122
const { role, grants } = parsed.data
122123

123124
if (role !== undefined) {
125+
if (inv.membershipIntent === 'external') {
126+
return NextResponse.json(
127+
{ error: 'Role updates are not valid on external workspace invitations' },
128+
{ status: 400 }
129+
)
130+
}
124131
if (!inv.organizationId) {
125132
return NextResponse.json(
126133
{ error: 'Role updates are only valid on organization-scoped invitations' },
@@ -187,6 +194,7 @@ export const PATCH = withRouteHandler(
187194
invitationId: id,
188195
targetEmail: inv.email,
189196
kind: inv.kind,
197+
membershipIntent: inv.membershipIntent,
190198
roleUpdate: role ?? null,
191199
grantUpdates: grantsToApply,
192200
},

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
workspace,
99
} from '@sim/db/schema'
1010
import { createLogger } from '@sim/logger'
11-
import { and, eq, inArray, sql } from 'drizzle-orm'
11+
import { and, eq, inArray, ne, sql } from 'drizzle-orm'
1212
import { type NextRequest, NextResponse } from 'next/server'
1313
import { getSession } from '@/lib/auth'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -124,14 +124,21 @@ export const GET = withRouteHandler(
124124
email: invitation.email,
125125
role: invitation.role,
126126
kind: invitation.kind,
127+
membershipIntent: invitation.membershipIntent,
127128
createdAt: invitation.createdAt,
128129
expiresAt: invitation.expiresAt,
129130
inviteeName: user.name,
130131
inviteeImage: user.image,
131132
})
132133
.from(invitation)
133134
.leftJoin(user, sql`lower(${user.email}) = lower(${invitation.email})`)
134-
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
135+
.where(
136+
and(
137+
eq(invitation.organizationId, organizationId),
138+
eq(invitation.status, 'pending'),
139+
ne(invitation.membershipIntent, 'external')
140+
)
141+
)
135142

136143
const pendingInvitationIds = pendingInvitationRows.map((row) => row.id)
137144
const pendingGrants =
@@ -162,6 +169,7 @@ export const GET = withRouteHandler(
162169
email: row.email,
163170
role: row.role,
164171
kind: row.kind,
172+
membershipIntent: row.membershipIntent,
165173
createdAt: row.createdAt,
166174
expiresAt: row.expiresAt,
167175
inviteeName: row.inviteeName,

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { invitation, member, organization, subscription } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, count, eq, inArray } from 'drizzle-orm'
4+
import { and, count, eq, gt, inArray, ne } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
@@ -116,7 +116,14 @@ export const PUT = withRouteHandler(
116116
const [pendingCountRow] = await db
117117
.select({ count: count() })
118118
.from(invitation)
119-
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
119+
.where(
120+
and(
121+
eq(invitation.organizationId, organizationId),
122+
eq(invitation.status, 'pending'),
123+
ne(invitation.membershipIntent, 'external'),
124+
gt(invitation.expiresAt, new Date())
125+
)
126+
)
120127

121128
const memberCount = memberCountRow?.count ?? 0
122129
const pendingCount = pendingCountRow?.count ?? 0

apps/sim/app/api/workspaces/invitations/route.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ describe('POST /api/workspaces/invitations', () => {
253253
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
254254
})
255255

256-
it('rejects org-owned invites for users already in another organization', async () => {
256+
it('creates an external workspace invitation for users already in another organization', async () => {
257257
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
258258
id: 'workspace-1',
259259
name: 'Org Workspace',
@@ -288,9 +288,19 @@ describe('POST /api/workspaces/invitations', () => {
288288
const response = await POST(request)
289289
const data = await response.json()
290290

291-
expect(response.status).toBe(409)
292-
expect(data.error).toContain('already a member of another organization')
293-
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
291+
expect(response.status).toBe(200)
292+
expect(data.success).toBe(true)
293+
expect(data.invitation.membershipIntent).toBe('external')
294+
expect(mockValidateSeatAvailability).not.toHaveBeenCalled()
295+
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
296+
expect.objectContaining({
297+
kind: 'workspace',
298+
email: 'new@example.com',
299+
organizationId: 'org-1',
300+
membershipIntent: 'external',
301+
grants: [{ workspaceId: 'workspace-1', permission: 'read' }],
302+
})
303+
)
294304
})
295305

296306
it('creates a unified workspace invitation for a grandfathered workspace', async () => {

apps/sim/app/api/workspaces/invitations/route.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
3-
import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/schema'
3+
import {
4+
type InvitationMembershipIntent,
5+
permissions,
6+
type permissionTypeEnum,
7+
user,
8+
workspace,
9+
} from '@sim/db/schema'
410
import { createLogger } from '@sim/logger'
511
import { and, eq, isNull, sql } from 'drizzle-orm'
612
import { type NextRequest, NextResponse } from 'next/server'
@@ -123,6 +129,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
123129
)
124130
}
125131

132+
let membershipIntent: InvitationMembershipIntent = 'internal'
133+
126134
const existingUser = await db
127135
.select()
128136
.from(user)
@@ -152,23 +160,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
152160
)
153161
}
154162

155-
if (invitePolicy.requiresSeat && invitePolicy.organizationId) {
163+
if (invitePolicy.organizationId) {
156164
const existingMembership = await getUserOrganization(existingUser.id)
157165
if (
158166
existingMembership &&
159167
existingMembership.organizationId !== invitePolicy.organizationId
160168
) {
161-
return NextResponse.json(
162-
{
163-
error:
164-
'This user is already a member of another organization. They must leave it before joining this workspace.',
165-
email: normalizedEmail,
166-
},
167-
{ status: 409 }
168-
)
169-
}
170-
171-
if (!existingMembership) {
169+
membershipIntent = 'external'
170+
} else if (invitePolicy.requiresSeat && !existingMembership) {
172171
const seatValidation = await validateSeatAvailability(invitePolicy.organizationId, 1)
173172
if (!seatValidation.canInvite) {
174173
return NextResponse.json(
@@ -213,6 +212,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
213212
email: normalizedEmail,
214213
inviterId: session.user.id,
215214
organizationId: workspaceDetails.organizationId,
215+
membershipIntent,
216216
role: 'member',
217217
grants: [
218218
{
@@ -228,6 +228,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
228228
invitedBy: session.user.id,
229229
inviteeEmail: normalizedEmail,
230230
role: permission,
231+
membershipIntent,
231232
})
232233
} catch {
233234
// telemetry must not fail the operation
@@ -236,7 +237,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
236237
captureServerEvent(
237238
session.user.id,
238239
'workspace_member_invited',
239-
{ workspace_id: workspaceId, invitee_role: permission },
240+
{ workspace_id: workspaceId, invitee_role: permission, membership_intent: membershipIntent },
240241
{
241242
groups: { workspace: workspaceId },
242243
setOnce: { first_invitation_sent_at: new Date().toISOString() },
@@ -275,6 +276,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
275276
metadata: {
276277
targetEmail: normalizedEmail,
277278
targetRole: permission,
279+
membershipIntent,
278280
workspaceName: workspaceDetails.name,
279281
invitationId,
280282
},
@@ -288,6 +290,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
288290
workspaceId,
289291
email: normalizedEmail,
290292
permission,
293+
membershipIntent,
291294
expiresAt: undefined,
292295
},
293296
})

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export const PermissionsTable = ({
8989
permissionType:
9090
changes.permissionType !== undefined ? changes.permissionType : permissionType,
9191
isCurrentUser: user.email === session?.user?.email,
92+
isExternal: user.isExternal,
9293
}
9394
}) || [],
9495
[workspacePermissions?.users, existingUserPermissionChanges, session?.user?.email]
@@ -212,6 +213,11 @@ export const PermissionsTable = ({
212213
)}
213214
</Badge>
214215
)}
216+
{user.isExternal && (
217+
<Badge variant='default' className='text-caption'>
218+
External
219+
</Badge>
220+
)}
215221
{hasChanges && (
216222
<Badge variant='default' className='text-caption'>
217223
Modified

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ export interface UserPermissions {
88
permissionType: PermissionType
99
isCurrentUser?: boolean
1010
isPendingInvitation?: boolean
11+
isExternal?: boolean
1112
invitationId?: string
1213
}

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

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { useParams, useRouter } from 'next/navigation'
5+
import { useParams } from 'next/navigation'
66
import {
77
Button,
88
type FileInputOptions,
@@ -47,7 +47,6 @@ export function InviteModal({
4747
inviteDisabledReason = null,
4848
organizationId = null,
4949
}: InviteModalProps) {
50-
const router = useRouter()
5150
const formRef = useRef<HTMLFormElement>(null)
5251
const [emailItems, setEmailItems] = useState<TagItem[]>([])
5352
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
@@ -103,9 +102,9 @@ export function InviteModal({
103102
const isOutOfSeats = exceedsSeatCapacity || isAtSeatCapacity
104103
const seatLimitReason = hasSeatData
105104
? availableSeats === 0
106-
? `No available seats. Using ${usedSeats} of ${totalSeats}.`
105+
? `Internal invites may fail: using ${usedSeats} of ${totalSeats} seats. External workspace invites do not require seats.`
107106
: exceedsSeatCapacity
108-
? `Only ${availableSeats} seat${availableSeats === 1 ? '' : 's'} available.`
107+
? `Only ${availableSeats} internal seat${availableSeats === 1 ? '' : 's'} available. External workspace invites do not require seats.`
109108
: null
110109
: null
111110

@@ -421,23 +420,12 @@ export function InviteModal({
421420
[workspaceId, userPerms.canAdmin, resendCooldowns, resendingInvitationIds, resendInvitation]
422421
)
423422

424-
const handleUpgradeRedirect = useCallback(() => {
425-
if (!workspaceId) return
426-
onOpenChange(false)
427-
router.push(`/workspace/${workspaceId}/settings/subscription`)
428-
}, [onOpenChange, router, workspaceId])
429-
430423
const handleSubmit = useCallback(
431424
(e: React.FormEvent) => {
432425
e.preventDefault()
433426

434427
setErrorMessage(null)
435428

436-
if (isOutOfSeats) {
437-
handleUpgradeRedirect()
438-
return
439-
}
440-
441429
if (!canInviteMembers || validEmails.length === 0 || !workspaceId) {
442430
return
443431
}
@@ -472,15 +460,7 @@ export function InviteModal({
472460
}
473461
)
474462
},
475-
[
476-
canInviteMembers,
477-
isOutOfSeats,
478-
handleUpgradeRedirect,
479-
validEmails,
480-
workspaceId,
481-
userPermissions,
482-
batchSendInvitations,
483-
]
463+
[canInviteMembers, validEmails, workspaceId, userPermissions, batchSendInvitations]
484464
)
485465

486466
const resetState = useCallback(() => {
@@ -504,6 +484,7 @@ export function InviteModal({
504484
email: inv.email,
505485
permissionType: inv.permissionType,
506486
isPendingInvitation: true,
487+
isExternal: inv.isExternal,
507488
invitationId: inv.invitationId,
508489
})),
509490
[pendingInvitations]
@@ -641,10 +622,6 @@ export function InviteModal({
641622
type='button'
642623
variant='primary'
643624
onClick={() => {
644-
if (isOutOfSeats) {
645-
handleUpgradeRedirect()
646-
return
647-
}
648625
formRef.current?.requestSubmit()
649626
}}
650627
disabled={
@@ -653,7 +630,7 @@ export function InviteModal({
653630
isSubmitting ||
654631
isSaving ||
655632
!workspaceId ||
656-
(!isOutOfSeats && !hasNewInvites)
633+
!hasNewInvites
657634
}
658635
className='ml-auto'
659636
>
@@ -663,9 +640,7 @@ export function InviteModal({
663640
? 'Admin Access Required'
664641
: isSubmitting
665642
? 'Inviting...'
666-
: isOutOfSeats
667-
? 'Upgrade to invite'
668-
: 'Invite'}
643+
: 'Invite'}
669644
</Button>
670645
</ModalFooter>
671646
</form>

apps/sim/hooks/queries/invitations.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface PendingInvitationRow {
1717
workspaceId: string
1818
email: string
1919
permission: 'admin' | 'write' | 'read'
20+
membershipIntent?: 'internal' | 'external'
2021
status: string
2122
createdAt: string
2223
}
@@ -25,6 +26,7 @@ export interface WorkspaceInvitation {
2526
email: string
2627
permissionType: 'admin' | 'write' | 'read'
2728
isPendingInvitation: boolean
29+
isExternal: boolean
2830
invitationId?: string
2931
}
3032

@@ -49,6 +51,7 @@ async function fetchPendingInvitations(
4951
email: inv.email,
5052
permissionType: inv.permission,
5153
isPendingInvitation: true,
54+
isExternal: inv.membershipIntent === 'external',
5255
invitationId: inv.id,
5356
})) || []
5457
)

0 commit comments

Comments
 (0)