Skip to content

Commit 86ff5dd

Browse files
committed
edge case improvements
1 parent db9fd32 commit 86ff5dd

14 files changed

Lines changed: 566 additions & 137 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@ export const DELETE = withRouteHandler(
342342
removedBy: session.user.id,
343343
workspaceAccessRevoked: externalResult.workspaceAccessRevoked,
344344
permissionGroupsRevoked: externalResult.permissionGroupsRevoked,
345+
credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked,
346+
pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled,
345347
})
346348

347349
recordAudit({
@@ -360,6 +362,8 @@ export const DELETE = withRouteHandler(
360362
membershipType: 'external',
361363
workspaceAccessRevoked: externalResult.workspaceAccessRevoked,
362364
permissionGroupsRevoked: externalResult.permissionGroupsRevoked,
365+
credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked,
366+
pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled,
363367
},
364368
request,
365369
})
@@ -374,6 +378,8 @@ export const DELETE = withRouteHandler(
374378
membershipType: 'external',
375379
workspaceAccessRevoked: externalResult.workspaceAccessRevoked,
376380
permissionGroupsRevoked: externalResult.permissionGroupsRevoked,
381+
credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked,
382+
pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled,
377383
},
378384
})
379385
}

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

Lines changed: 4 additions & 10 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, isNull, ne, sql } from 'drizzle-orm'
11+
import { and, eq, inArray, isNull, 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'
@@ -57,7 +57,7 @@ export const GET = withRouteHandler(
5757
const orgWorkspaces = await db
5858
.select({ id: workspace.id, name: workspace.name })
5959
.from(workspace)
60-
.where(eq(workspace.organizationId, organizationId))
60+
.where(and(eq(workspace.organizationId, organizationId), isNull(workspace.archivedAt)))
6161

6262
const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id)
6363
const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name]))
@@ -201,13 +201,7 @@ export const GET = withRouteHandler(
201201
})
202202
.from(invitation)
203203
.leftJoin(user, sql`lower(${user.email}) = lower(${invitation.email})`)
204-
.where(
205-
and(
206-
eq(invitation.organizationId, organizationId),
207-
eq(invitation.status, 'pending'),
208-
ne(invitation.membershipIntent, 'external')
209-
)
210-
)
204+
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
211205

212206
const pendingInvitationIds = pendingInvitationRows.map((row) => row.id)
213207
const pendingGrants =
@@ -236,7 +230,7 @@ export const GET = withRouteHandler(
236230
const pendingInvitations = pendingInvitationRows.map((row) => ({
237231
id: row.id,
238232
email: row.email,
239-
role: row.role,
233+
role: row.membershipIntent === 'external' ? 'external' : row.role,
240234
kind: row.kind,
241235
membershipIntent: row.membershipIntent,
242236
createdAt: row.createdAt,

apps/sim/app/api/workspaces/members/[id]/route.ts

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
3-
import { permissions, workspace } from '@sim/db/schema'
3+
import { permissionGroupMember, permissions, workspace } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5+
import { generateId } from '@sim/utils/id'
56
import { and, eq } from 'drizzle-orm'
67
import { type NextRequest, NextResponse } from 'next/server'
78
import { z } from 'zod'
89
import { getSession } from '@/lib/auth'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10-
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
11+
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
1112
import { captureServerEvent } from '@/lib/posthog/server'
1213
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
1314

@@ -32,7 +33,10 @@ export const DELETE = withRouteHandler(
3233
const { workspaceId } = body
3334

3435
const workspaceRow = await db
35-
.select({ billedAccountUserId: workspace.billedAccountUserId })
36+
.select({
37+
ownerId: workspace.ownerId,
38+
billedAccountUserId: workspace.billedAccountUserId,
39+
})
3640
.from(workspace)
3741
.where(eq(workspace.id, workspaceId))
3842
.limit(1)
@@ -61,7 +65,10 @@ export const DELETE = withRouteHandler(
6165
)
6266
.then((rows) => rows[0])
6367

64-
if (!userPermission) {
68+
const isRemovingWorkspaceOwner = workspaceRow[0].ownerId === userId
69+
const isOwnerOnlyRemoval = isRemovingWorkspaceOwner && !userPermission
70+
71+
if (!userPermission && !isOwnerOnlyRemoval) {
6572
return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 })
6673
}
6774

@@ -73,8 +80,19 @@ export const DELETE = withRouteHandler(
7380
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
7481
}
7582

83+
if (
84+
isRemovingWorkspaceOwner &&
85+
!isSelf &&
86+
session.user.id !== workspaceRow[0].billedAccountUserId
87+
) {
88+
return NextResponse.json(
89+
{ error: 'Only the workspace owner or billing account can remove the workspace owner' },
90+
{ status: 403 }
91+
)
92+
}
93+
7694
// Prevent removing yourself if you're the last admin
77-
if (isSelf && userPermission.permissionType === 'admin') {
95+
if (isSelf && userPermission?.permissionType === 'admin' && !isRemovingWorkspaceOwner) {
7896
const otherAdmins = await db
7997
.select()
8098
.from(permissions)
@@ -95,18 +113,73 @@ export const DELETE = withRouteHandler(
95113
}
96114
}
97115

98-
// Delete the user's permissions for this workspace
99-
await db
100-
.delete(permissions)
101-
.where(
102-
and(
103-
eq(permissions.userId, userId),
104-
eq(permissions.entityType, 'workspace'),
105-
eq(permissions.entityId, workspaceId)
116+
const ownershipTransferred = await db.transaction(async (tx) => {
117+
let didTransferOwnership = false
118+
119+
if (isRemovingWorkspaceOwner) {
120+
const newOwnerId = workspaceRow[0].billedAccountUserId
121+
122+
await tx
123+
.update(workspace)
124+
.set({ ownerId: newOwnerId, updatedAt: new Date() })
125+
.where(eq(workspace.id, workspaceId))
126+
127+
const [existingNewOwnerPermission] = await tx
128+
.select({ id: permissions.id })
129+
.from(permissions)
130+
.where(
131+
and(
132+
eq(permissions.userId, newOwnerId),
133+
eq(permissions.entityType, 'workspace'),
134+
eq(permissions.entityId, workspaceId)
135+
)
136+
)
137+
.limit(1)
138+
139+
if (existingNewOwnerPermission) {
140+
await tx
141+
.update(permissions)
142+
.set({ permissionType: 'admin', updatedAt: new Date() })
143+
.where(eq(permissions.id, existingNewOwnerPermission.id))
144+
} else {
145+
const now = new Date()
146+
await tx.insert(permissions).values({
147+
id: generateId(),
148+
userId: newOwnerId,
149+
entityType: 'workspace',
150+
entityId: workspaceId,
151+
permissionType: 'admin',
152+
createdAt: now,
153+
updatedAt: now,
154+
})
155+
}
156+
157+
didTransferOwnership = true
158+
}
159+
160+
await tx
161+
.delete(permissions)
162+
.where(
163+
and(
164+
eq(permissions.userId, userId),
165+
eq(permissions.entityType, 'workspace'),
166+
eq(permissions.entityId, workspaceId)
167+
)
106168
)
107-
)
108169

109-
await revokeWorkspaceCredentialMemberships(workspaceId, userId)
170+
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
171+
172+
await tx
173+
.delete(permissionGroupMember)
174+
.where(
175+
and(
176+
eq(permissionGroupMember.userId, userId),
177+
eq(permissionGroupMember.workspaceId, workspaceId)
178+
)
179+
)
180+
181+
return didTransferOwnership
182+
})
110183

111184
captureServerEvent(
112185
session.user.id,
@@ -126,8 +199,9 @@ export const DELETE = withRouteHandler(
126199
description: isSelf ? 'Left the workspace' : `Removed member ${userId} from the workspace`,
127200
metadata: {
128201
removedUserId: userId,
129-
removedUserRole: userPermission.permissionType,
202+
removedUserRole: userPermission?.permissionType ?? 'owner',
130203
selfRemoval: isSelf,
204+
ownershipTransferred,
131205
},
132206
request: req,
133207
})

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,11 @@ function apportionCredits(
7575
return result
7676
}
7777

78-
function RoleBadge({ role }: { role: string }) {
79-
const variant = role === 'owner' ? 'blue-secondary' : 'gray-secondary'
78+
function RoleBadge({ memberRole }: { memberRole: string }) {
79+
const variant = memberRole === 'owner' ? 'blue-secondary' : 'gray-secondary'
8080
return (
8181
<Badge variant={variant} size='sm'>
82-
{role.charAt(0).toUpperCase() + role.slice(1)}
82+
{memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
8383
</Badge>
8484
)
8585
}
@@ -550,7 +550,7 @@ export function OrganizationRoster({
550550
isExternal ||
551551
!canEditRoles ||
552552
m.userId === currentUserId ? (
553-
<RoleBadge role={m.role} />
553+
<RoleBadge memberRole={m.role} />
554554
) : (
555555
<OrgRoleSelector
556556
value={(m.role === 'admin' ? 'admin' : 'member') as OrgRole}
@@ -634,6 +634,7 @@ export function OrganizationRoster({
634634
{filteredInvitations.map((inv) => {
635635
const rowKey = `invite-${inv.id}`
636636
const expanded = expandedRows.has(rowKey)
637+
const isExternal = inv.membershipIntent === 'external'
637638
const isResending = resendingIds.has(inv.id)
638639
const isCancelling = cancellingIds.has(inv.id)
639640
const cooldown = resendCooldowns[inv.id] ?? 0
@@ -664,7 +665,9 @@ export function OrganizationRoster({
664665
</button>
665666
</TableCell>
666667
<TableCell>
667-
{isAdminOrOwner ? (
668+
{isExternal ? (
669+
<RoleBadge memberRole='external' />
670+
) : isAdminOrOwner ? (
668671
<OrgRoleSelector
669672
value={(inv.role === 'admin' ? 'admin' : 'member') as OrgRole}
670673
onChange={(next) =>
@@ -681,7 +684,7 @@ export function OrganizationRoster({
681684
disabled={updateInvitation.isPending}
682685
/>
683686
) : (
684-
<RoleBadge role={inv.role} />
687+
<RoleBadge memberRole={inv.role} />
685688
)}
686689
</TableCell>
687690
<TableCell className='text-right'>

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface RemoveMemberDialogProps {
1414
shouldReduceSeats: boolean
1515
isSelfRemoval?: boolean
1616
isExternalRemoval?: boolean
17+
isSubmitting?: boolean
1718
error?: Error | null
1819
onOpenChange: (open: boolean) => void
1920
onShouldReduceSeatsChange: (shouldReduce: boolean) => void
@@ -32,6 +33,7 @@ export function RemoveMemberDialog({
3233
onCancel,
3334
isSelfRemoval = false,
3435
isExternalRemoval = false,
36+
isSubmitting = false,
3537
}: RemoveMemberDialogProps) {
3638
const title = isSelfRemoval
3739
? 'Leave Organization'
@@ -92,11 +94,12 @@ export function RemoveMemberDialog({
9294
)}
9395
</ModalBody>
9496
<ModalFooter>
95-
<Button variant='default' onClick={onCancel}>
97+
<Button variant='default' disabled={isSubmitting} onClick={onCancel}>
9698
Cancel
9799
</Button>
98100
<Button
99101
variant='destructive'
102+
disabled={isSubmitting}
100103
onClick={() => onConfirmRemove(isExternalRemoval ? false : shouldReduceSeats)}
101104
>
102105
{isSelfRemoval ? 'Leave Organization' : 'Remove'}

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ export function TransferOwnershipDialog({
5353
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
5454

5555
const candidates = useMemo(() => {
56-
const others = members.filter((m) => m.userId !== currentUserId && m.role !== 'owner')
56+
const others = members.filter(
57+
(m) => m.userId !== currentUserId && m.role !== 'owner' && m.role !== 'external'
58+
)
5759
others.sort((a, b) => {
5860
if (a.role === 'admin' && b.role !== 'admin') return -1
5961
if (a.role !== 'admin' && b.role === 'admin') return 1
@@ -66,7 +68,9 @@ export function TransferOwnershipDialog({
6668
)
6769
}, [members, currentUserId, search])
6870

69-
const hasCandidates = members.some((m) => m.userId !== currentUserId && m.role !== 'owner')
71+
const hasCandidates = members.some(
72+
(m) => m.userId !== currentUserId && m.role !== 'owner' && m.role !== 'external'
73+
)
7074

7175
const handleClose = (next: boolean) => {
7276
if (!next) {

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,18 @@ export function TeamManagement() {
222222
orgId: activeOrganization?.id,
223223
shouldReduceSeats,
224224
})
225+
226+
if (shouldReduceSeats && totalSeats > 1) {
227+
try {
228+
await updateSeatsMutation.mutateAsync({
229+
orgId: activeOrganization.id,
230+
seats: totalSeats - 1,
231+
})
232+
} catch (seatError) {
233+
logger.error('Failed to reduce seats after removing member', seatError)
234+
}
235+
}
236+
225237
setRemoveMemberDialog({
226238
open: false,
227239
memberId: '',
@@ -243,6 +255,8 @@ export function TeamManagement() {
243255
session?.user?.id,
244256
activeOrganization?.id,
245257
removeMemberMutation,
258+
totalSeats,
259+
updateSeatsMutation,
246260
]
247261
)
248262

@@ -504,6 +518,7 @@ export function TeamManagement() {
504518
shouldReduceSeats={removeMemberDialog.shouldReduceSeats}
505519
isSelfRemoval={removeMemberDialog.isSelfRemoval}
506520
isExternalRemoval={removeMemberDialog.isExternalRemoval}
521+
isSubmitting={removeMemberMutation.isPending}
507522
error={removeMemberMutation.error}
508523
onOpenChange={(open: boolean) => {
509524
if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false })

0 commit comments

Comments
 (0)