Skip to content

Commit e584d5f

Browse files
committed
add feature for owner to leave + admin route
1 parent 2af6bb2 commit e584d5f

15 files changed

Lines changed: 1247 additions & 93 deletions

File tree

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

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
88
import { getSession } from '@/lib/auth'
9+
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
910
import { getUserUsageData } from '@/lib/billing/core/usage'
1011
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
1112

@@ -193,29 +194,21 @@ export async function PUT(
193194
return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 })
194195
}
195196

196-
if (role === 'owner' && userMember[0].role !== 'owner') {
197+
if (role === 'owner') {
197198
return NextResponse.json(
198-
{ error: 'Only the current owner can transfer ownership' },
199-
{ status: 403 }
199+
{
200+
error:
201+
'Ownership transfer is not supported via this endpoint. Use POST /organizations/[id]/transfer-ownership instead.',
202+
},
203+
{ status: 400 }
200204
)
201205
}
202206

203-
const isOwnershipTransfer = role === 'owner'
204-
205-
const updatedMember = await db.transaction(async (tx) => {
206-
if (isOwnershipTransfer) {
207-
await tx
208-
.update(member)
209-
.set({ role: 'admin' })
210-
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
211-
}
212-
213-
return tx
214-
.update(member)
215-
.set({ role })
216-
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
217-
.returning()
218-
})
207+
const updatedMember = await db
208+
.update(member)
209+
.set({ role })
210+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
211+
.returning()
219212

220213
if (updatedMember.length === 0) {
221214
return NextResponse.json({ error: 'Failed to update member role' }, { status: 500 })
@@ -331,6 +324,18 @@ export async function DELETE(
331324
return NextResponse.json({ error: result.error }, { status: 500 })
332325
}
333326

327+
if (session.user.id === targetUserId) {
328+
try {
329+
await setActiveOrganizationForCurrentSession(null)
330+
} catch (clearError) {
331+
logger.warn('Failed to clear active organization after self-removal', {
332+
userId: session.user.id,
333+
organizationId,
334+
error: clearError,
335+
})
336+
}
337+
}
338+
334339
logger.info('Organization member removed', {
335340
organizationId,
336341
removedMemberId: targetUserId,
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { db } from '@sim/db'
2+
import { member, user } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
8+
import { getSession } from '@/lib/auth'
9+
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
10+
import {
11+
removeUserFromOrganization,
12+
transferOrganizationOwnership,
13+
} from '@/lib/billing/organizations/membership'
14+
15+
const logger = createLogger('TransferOwnershipAPI')
16+
17+
const transferOwnershipSchema = z.object({
18+
newOwnerUserId: z.string().min(1),
19+
alsoLeave: z.boolean().optional().default(false),
20+
})
21+
22+
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
23+
try {
24+
const session = await getSession()
25+
if (!session?.user?.id) {
26+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
27+
}
28+
29+
const { id: organizationId } = await params
30+
const body = await request.json().catch(() => ({}))
31+
const validation = transferOwnershipSchema.safeParse(body)
32+
if (!validation.success) {
33+
return NextResponse.json(
34+
{ error: validation.error.errors[0]?.message ?? 'Invalid request' },
35+
{ status: 400 }
36+
)
37+
}
38+
39+
const { newOwnerUserId, alsoLeave } = validation.data
40+
41+
if (newOwnerUserId === session.user.id) {
42+
return NextResponse.json(
43+
{ error: 'New owner must differ from current owner' },
44+
{ status: 400 }
45+
)
46+
}
47+
48+
const [currentOwnerMember] = await db
49+
.select({ role: member.role })
50+
.from(member)
51+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
52+
.limit(1)
53+
54+
if (!currentOwnerMember) {
55+
return NextResponse.json(
56+
{ error: 'You are not a member of this organization' },
57+
{ status: 403 }
58+
)
59+
}
60+
61+
if (currentOwnerMember.role !== 'owner') {
62+
return NextResponse.json(
63+
{ error: 'Only the current owner can transfer ownership' },
64+
{ status: 403 }
65+
)
66+
}
67+
68+
const [targetMember] = await db
69+
.select({
70+
id: member.id,
71+
role: member.role,
72+
email: user.email,
73+
name: user.name,
74+
})
75+
.from(member)
76+
.innerJoin(user, eq(member.userId, user.id))
77+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, newOwnerUserId)))
78+
.limit(1)
79+
80+
if (!targetMember) {
81+
return NextResponse.json(
82+
{ error: 'Target user is not a member of this organization' },
83+
{ status: 400 }
84+
)
85+
}
86+
87+
const transferResult = await transferOrganizationOwnership({
88+
organizationId,
89+
currentOwnerUserId: session.user.id,
90+
newOwnerUserId,
91+
})
92+
93+
if (!transferResult.success) {
94+
return NextResponse.json(
95+
{ error: transferResult.error ?? 'Failed to transfer ownership' },
96+
{ status: 500 }
97+
)
98+
}
99+
100+
recordAudit({
101+
workspaceId: null,
102+
actorId: session.user.id,
103+
actorName: session.user.name ?? undefined,
104+
actorEmail: session.user.email ?? undefined,
105+
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
106+
resourceType: AuditResourceType.ORGANIZATION,
107+
resourceId: organizationId,
108+
description: `Transferred ownership to ${targetMember.email}`,
109+
metadata: {
110+
targetUserId: newOwnerUserId,
111+
targetEmail: targetMember.email ?? undefined,
112+
targetName: targetMember.name ?? undefined,
113+
workspacesReassigned: transferResult.workspacesReassigned,
114+
billedAccountReassigned: transferResult.billedAccountReassigned,
115+
overageMigrated: transferResult.overageMigrated,
116+
billingBlockInherited: transferResult.billingBlockInherited,
117+
},
118+
request,
119+
})
120+
121+
if (!alsoLeave) {
122+
return NextResponse.json({
123+
success: true,
124+
transferred: true,
125+
left: false,
126+
details: {
127+
workspacesReassigned: transferResult.workspacesReassigned,
128+
billedAccountReassigned: transferResult.billedAccountReassigned,
129+
overageMigrated: transferResult.overageMigrated,
130+
billingBlockInherited: transferResult.billingBlockInherited,
131+
},
132+
})
133+
}
134+
135+
const [selfMember] = await db
136+
.select({ id: member.id })
137+
.from(member)
138+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
139+
.limit(1)
140+
141+
if (!selfMember) {
142+
return NextResponse.json({
143+
success: true,
144+
transferred: true,
145+
left: true,
146+
details: {
147+
workspacesReassigned: transferResult.workspacesReassigned,
148+
billedAccountReassigned: transferResult.billedAccountReassigned,
149+
overageMigrated: transferResult.overageMigrated,
150+
billingBlockInherited: transferResult.billingBlockInherited,
151+
},
152+
})
153+
}
154+
155+
const removeResult = await removeUserFromOrganization({
156+
userId: session.user.id,
157+
organizationId,
158+
memberId: selfMember.id,
159+
})
160+
161+
if (!removeResult.success) {
162+
logger.error('Transfer succeeded but self-removal failed', {
163+
organizationId,
164+
userId: session.user.id,
165+
error: removeResult.error,
166+
})
167+
return NextResponse.json(
168+
{
169+
success: true,
170+
transferred: true,
171+
left: false,
172+
warning: removeResult.error ?? 'Failed to leave after transfer',
173+
},
174+
{ status: 207 }
175+
)
176+
}
177+
178+
try {
179+
await setActiveOrganizationForCurrentSession(null)
180+
} catch (clearError) {
181+
logger.warn('Failed to clear active organization after transfer-and-leave', {
182+
userId: session.user.id,
183+
organizationId,
184+
error: clearError,
185+
})
186+
}
187+
188+
recordAudit({
189+
workspaceId: null,
190+
actorId: session.user.id,
191+
actorName: session.user.name ?? undefined,
192+
actorEmail: session.user.email ?? undefined,
193+
action: AuditAction.ORG_MEMBER_REMOVED,
194+
resourceType: AuditResourceType.ORGANIZATION,
195+
resourceId: organizationId,
196+
description: 'Left the organization after transferring ownership',
197+
metadata: {
198+
targetUserId: session.user.id,
199+
wasSelfRemoval: true,
200+
followedOwnershipTransfer: true,
201+
},
202+
request,
203+
})
204+
205+
return NextResponse.json({
206+
success: true,
207+
transferred: true,
208+
left: true,
209+
details: {
210+
workspacesReassigned: transferResult.workspacesReassigned,
211+
billedAccountReassigned: transferResult.billedAccountReassigned,
212+
overageMigrated: transferResult.overageMigrated,
213+
billingBlockInherited: transferResult.billingBlockInherited,
214+
billingActions: removeResult.billingActions,
215+
},
216+
})
217+
} catch (error) {
218+
logger.error('Failed to transfer organization ownership', {
219+
organizationId: (await params).id,
220+
error,
221+
})
222+
return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 })
223+
}
224+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
169169

170170
if (existingMember) {
171171
if (existingMember.organizationId === organizationId) {
172+
if (existingMember.role === 'owner') {
173+
return badRequestResponse(
174+
'Cannot change the owner role via this endpoint. Use POST /api/v1/admin/organizations/[id]/transfer-ownership instead.'
175+
)
176+
}
177+
172178
if (existingMember.role !== body.role) {
173179
await db.update(member).set({ role: body.role }).where(eq(member.id, existingMember.id))
174180

0 commit comments

Comments
 (0)