Skip to content

Commit db9fd32

Browse files
committed
address comments
1 parent c52a752 commit db9fd32

8 files changed

Lines changed: 328 additions & 27 deletions

File tree

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

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import { z } from 'zod'
88
import { getSession } from '@/lib/auth'
99
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
1010
import { getUserUsageData } from '@/lib/billing/core/usage'
11-
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
11+
import {
12+
removeExternalUserFromOrganizationWorkspaces,
13+
removeUserFromOrganization,
14+
} from '@/lib/billing/organizations/membership'
1215
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1316

1417
const logger = createLogger('OrganizationMemberAPI')
@@ -311,7 +314,68 @@ export const DELETE = withRouteHandler(
311314
.limit(1)
312315

313316
if (targetMember.length === 0) {
314-
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
317+
const [targetUser] = await db
318+
.select({ id: user.id, email: user.email, name: user.name })
319+
.from(user)
320+
.where(eq(user.id, targetUserId))
321+
.limit(1)
322+
323+
if (!targetUser) {
324+
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
325+
}
326+
327+
const externalResult = await removeExternalUserFromOrganizationWorkspaces({
328+
userId: targetUserId,
329+
organizationId,
330+
})
331+
332+
if (!externalResult.success) {
333+
return NextResponse.json(
334+
{ error: externalResult.error || 'External workspace member not found' },
335+
{ status: externalResult.error === 'External workspace member not found' ? 404 : 500 }
336+
)
337+
}
338+
339+
logger.info('External workspace member removed from organization workspaces', {
340+
organizationId,
341+
removedMemberId: targetUserId,
342+
removedBy: session.user.id,
343+
workspaceAccessRevoked: externalResult.workspaceAccessRevoked,
344+
permissionGroupsRevoked: externalResult.permissionGroupsRevoked,
345+
})
346+
347+
recordAudit({
348+
workspaceId: null,
349+
actorId: session.user.id,
350+
action: AuditAction.ORG_MEMBER_REMOVED,
351+
resourceType: AuditResourceType.ORGANIZATION,
352+
resourceId: organizationId,
353+
actorName: session.user.name ?? undefined,
354+
actorEmail: session.user.email ?? undefined,
355+
description: `Removed external workspace member ${targetUserId} from organization`,
356+
metadata: {
357+
targetUserId,
358+
targetEmail: targetUser.email ?? undefined,
359+
targetName: targetUser.name ?? undefined,
360+
membershipType: 'external',
361+
workspaceAccessRevoked: externalResult.workspaceAccessRevoked,
362+
permissionGroupsRevoked: externalResult.permissionGroupsRevoked,
363+
},
364+
request,
365+
})
366+
367+
return NextResponse.json({
368+
success: true,
369+
message: 'External member removed successfully',
370+
data: {
371+
removedMemberId: targetUserId,
372+
removedBy: session.user.id,
373+
removedAt: new Date().toISOString(),
374+
membershipType: 'external',
375+
workspaceAccessRevoked: externalResult.workspaceAccessRevoked,
376+
permissionGroupsRevoked: externalResult.permissionGroupsRevoked,
377+
},
378+
})
315379
}
316380

317381
const result = await removeUserFromOrganization({

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

Lines changed: 71 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, ne, sql } from 'drizzle-orm'
11+
import { and, eq, inArray, isNull, 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'
@@ -118,6 +118,75 @@ export const GET = withRouteHandler(
118118
workspaces: permissionsByUser.get(row.userId) ?? [],
119119
}))
120120

121+
const externalPermissionRows =
122+
orgWorkspaceIds.length > 0
123+
? await db
124+
.select({
125+
userId: user.id,
126+
userName: user.name,
127+
userEmail: user.email,
128+
userImage: user.image,
129+
workspaceId: permissions.entityId,
130+
permission: permissions.permissionType,
131+
createdAt: permissions.createdAt,
132+
})
133+
.from(permissions)
134+
.innerJoin(user, eq(permissions.userId, user.id))
135+
.leftJoin(
136+
member,
137+
and(eq(member.userId, user.id), eq(member.organizationId, organizationId))
138+
)
139+
.where(
140+
and(
141+
eq(permissions.entityType, 'workspace'),
142+
inArray(permissions.entityId, orgWorkspaceIds),
143+
isNull(member.id)
144+
)
145+
)
146+
: []
147+
148+
const externalMembersByUser = new Map<
149+
string,
150+
{
151+
memberId: string
152+
userId: string
153+
role: 'external'
154+
createdAt: Date
155+
name: string
156+
email: string
157+
image: string | null
158+
workspaces: RosterWorkspaceAccess[]
159+
}
160+
>()
161+
162+
for (const row of externalPermissionRows) {
163+
const existing = externalMembersByUser.get(row.userId)
164+
const workspaceAccess: RosterWorkspaceAccess = {
165+
workspaceId: row.workspaceId,
166+
workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace',
167+
permission: row.permission,
168+
}
169+
170+
if (existing) {
171+
existing.workspaces.push(workspaceAccess)
172+
if (row.createdAt < existing.createdAt) existing.createdAt = row.createdAt
173+
continue
174+
}
175+
176+
externalMembersByUser.set(row.userId, {
177+
memberId: `external-${row.userId}`,
178+
userId: row.userId,
179+
role: 'external',
180+
createdAt: row.createdAt,
181+
name: row.userName,
182+
email: row.userEmail,
183+
image: row.userImage,
184+
workspaces: [workspaceAccess],
185+
})
186+
}
187+
188+
const rosterMembers = [...members, ...externalMembersByUser.values()]
189+
121190
const pendingInvitationRows = await db
122191
.select({
123192
id: invitation.id,
@@ -180,7 +249,7 @@ export const GET = withRouteHandler(
180249
return NextResponse.json({
181250
success: true,
182251
data: {
183-
members,
252+
members: rosterMembers,
184253
pendingInvitations,
185254
workspaces: orgWorkspaces,
186255
},

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ export function OrganizationRoster({
521521
const rowKey = `member-${m.memberId}`
522522
const expanded = expandedRows.has(rowKey)
523523
const isSelf = m.email === currentUserEmail
524+
const isExternal = m.role === 'external'
524525
const credits = memberCredits[m.userId] ?? 0
525526
const canRemove = isAdminOrOwner && m.role !== 'owner' && !isSelf
526527
const canTransferAndLeave = isSelf && m.role === 'owner' && !!onTransferOwnership
@@ -545,7 +546,10 @@ export function OrganizationRoster({
545546
</button>
546547
</TableCell>
547548
<TableCell>
548-
{m.role === 'owner' || !canEditRoles || m.userId === currentUserId ? (
549+
{m.role === 'owner' ||
550+
isExternal ||
551+
!canEditRoles ||
552+
m.userId === currentUserId ? (
549553
<RoleBadge role={m.role} />
550554
) : (
551555
<OrgRoleSelector

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface RemoveMemberDialogProps {
1313
memberName: string
1414
shouldReduceSeats: boolean
1515
isSelfRemoval?: boolean
16+
isExternalRemoval?: boolean
1617
error?: Error | null
1718
onOpenChange: (open: boolean) => void
1819
onShouldReduceSeatsChange: (shouldReduce: boolean) => void
@@ -30,15 +31,29 @@ export function RemoveMemberDialog({
3031
onConfirmRemove,
3132
onCancel,
3233
isSelfRemoval = false,
34+
isExternalRemoval = false,
3335
}: RemoveMemberDialogProps) {
36+
const title = isSelfRemoval
37+
? 'Leave Organization'
38+
: isExternalRemoval
39+
? 'Remove External Member'
40+
: 'Remove Team Member'
41+
3442
return (
3543
<Modal open={open} onOpenChange={onOpenChange}>
3644
<ModalContent size='sm'>
37-
<ModalHeader>{isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'}</ModalHeader>
45+
<ModalHeader>{title}</ModalHeader>
3846
<ModalBody>
3947
<p className='text-[var(--text-secondary)]'>
4048
{isSelfRemoval ? (
4149
'Are you sure you want to leave this organization? You will lose access to all team resources.'
50+
) : isExternalRemoval ? (
51+
<>
52+
Are you sure you want to remove{' '}
53+
<span className='font-medium text-[var(--text-primary)]'>{memberName}</span> from
54+
all organization workspaces? Their workspace access and workspace credential access
55+
will be revoked.
56+
</>
4257
) : (
4358
<>
4459
Are you sure you want to remove{' '}
@@ -49,7 +64,7 @@ export function RemoveMemberDialog({
4964
This action cannot be undone.
5065
</p>
5166

52-
{!isSelfRemoval && (
67+
{!isSelfRemoval && !isExternalRemoval && (
5368
<div className='mt-4'>
5469
<div className='flex items-center gap-2'>
5570
<Checkbox
@@ -80,7 +95,10 @@ export function RemoveMemberDialog({
8095
<Button variant='default' onClick={onCancel}>
8196
Cancel
8297
</Button>
83-
<Button variant='destructive' onClick={() => onConfirmRemove(shouldReduceSeats)}>
98+
<Button
99+
variant='destructive'
100+
onClick={() => onConfirmRemove(isExternalRemoval ? false : shouldReduceSeats)}
101+
>
84102
{isSelfRemoval ? 'Leave Organization' : 'Remove'}
85103
</Button>
86104
</ModalFooter>

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@ import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
88
import { getPlanTierCredits, getPlanTierDollars } from '@/lib/billing/plan-helpers'
99
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
1010
import { getBaseUrl } from '@/lib/core/utils/urls'
11-
import {
12-
generateSlug,
13-
getUsedSeats,
14-
isAdminOrOwner,
15-
type Member,
16-
} from '@/lib/workspaces/organization'
11+
import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization'
1712
import {
1813
MemberInvitationCard,
1914
NoOrganizationView,
@@ -93,6 +88,7 @@ export function TeamManagement() {
9388
memberName: string
9489
shouldReduceSeats: boolean
9590
isSelfRemoval?: boolean
91+
isExternalRemoval?: boolean
9692
}>({ open: false, memberId: '', memberName: '', shouldReduceSeats: false })
9793
const [transferDialogOpen, setTransferDialogOpen] = useState(false)
9894
const [transferPortalError, setTransferPortalError] = useState<string | null>(null)
@@ -108,8 +104,8 @@ export function TeamManagement() {
108104
)
109105

110106
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
111-
const usedSeats = getUsedSeats(organization)
112107
const totalSeats = organizationBillingData?.data?.totalSeats ?? 0
108+
const usedSeats = organizationBillingData?.data?.usedSeats ?? 0
113109

114110
useEffect(() => {
115111
if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
@@ -209,6 +205,7 @@ export function TeamManagement() {
209205
memberName: displayName,
210206
shouldReduceSeats: false,
211207
isSelfRemoval: isLeavingSelf,
208+
isExternalRemoval: member.role === 'external',
212209
})
213210
},
214211
[session?.user, activeOrganization?.id]
@@ -230,6 +227,7 @@ export function TeamManagement() {
230227
memberId: '',
231228
memberName: '',
232229
shouldReduceSeats: false,
230+
isExternalRemoval: false,
233231
})
234232

235233
if (isSelfRemoval) {
@@ -446,7 +444,7 @@ export function TeamManagement() {
446444
subscriptionData={subscriptionData || null}
447445
isLoadingSubscription={isLoadingSubscription}
448446
totalSeats={totalSeats}
449-
usedSeats={usedSeats.used}
447+
usedSeats={usedSeats}
450448
isLoading={isLoading}
451449
onAddSeatDialog={handleAddSeatDialog}
452450
/>
@@ -466,7 +464,7 @@ export function TeamManagement() {
466464
onLoadUserWorkspaces={async () => {}}
467465
onWorkspaceToggle={handleWorkspaceToggle}
468466
inviteSuccess={inviteSuccess}
469-
availableSeats={Math.max(0, totalSeats - usedSeats.used)}
467+
availableSeats={Math.max(0, totalSeats - usedSeats)}
470468
maxSeats={totalSeats}
471469
invitationError={inviteMutation.error}
472470
isLoadingWorkspaces={isLoadingWorkspaces}
@@ -505,6 +503,7 @@ export function TeamManagement() {
505503
memberName={removeMemberDialog.memberName}
506504
shouldReduceSeats={removeMemberDialog.shouldReduceSeats}
507505
isSelfRemoval={removeMemberDialog.isSelfRemoval}
506+
isExternalRemoval={removeMemberDialog.isExternalRemoval}
508507
error={removeMemberMutation.error}
509508
onOpenChange={(open: boolean) => {
510509
if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false })
@@ -523,6 +522,7 @@ export function TeamManagement() {
523522
memberName: '',
524523
shouldReduceSeats: false,
525524
isSelfRemoval: false,
525+
isExternalRemoval: false,
526526
})
527527
}
528528
/>

apps/sim/hooks/queries/organization.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export type RosterWorkspaceAccess = {
3333
export type RosterMember = {
3434
memberId: string
3535
userId: string
36-
role: string
36+
role: 'owner' | 'admin' | 'member' | 'external'
3737
createdAt: string
3838
name: string
3939
email: string

apps/sim/lib/billing/core/organization.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
2-
import { member, organization, user, userStats } from '@sim/db/schema'
2+
import { invitation, member, organization, user, userStats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
4+
import { and, count, eq, gt, ne } from 'drizzle-orm'
55
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
66
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
77
import {
@@ -172,6 +172,19 @@ export async function getOrganizationBillingData(
172172

173173
const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0
174174

175+
const [pendingInvitationCount] = await db
176+
.select({ count: count() })
177+
.from(invitation)
178+
.where(
179+
and(
180+
eq(invitation.organizationId, organizationId),
181+
eq(invitation.status, 'pending'),
182+
ne(invitation.membershipIntent, 'external'),
183+
gt(invitation.expiresAt, new Date())
184+
)
185+
)
186+
const usedSeats = members.length + (pendingInvitationCount?.count ?? 0)
187+
175188
const billingPeriodStart = subscription.periodStart || null
176189
const billingPeriodEnd = subscription.periodEnd || null
177190

@@ -181,7 +194,7 @@ export async function getOrganizationBillingData(
181194
subscriptionPlan: subscription.plan,
182195
subscriptionStatus: subscription.status || 'inactive',
183196
totalSeats: effectiveSeats,
184-
usedSeats: members.length,
197+
usedSeats,
185198
seatsCount: licensedSeats,
186199
totalCurrentUsage: roundCurrency(totalCurrentUsage),
187200
totalUsageLimit: roundCurrency(totalUsageLimit),

0 commit comments

Comments
 (0)