Skip to content

Commit 712e58a

Browse files
TheodoreSpeaksTheodore Li
andauthored
fix(admin): delete workspaces on ban (#4029)
* fix(admin): delete workspaces on ban * Fix lint * Wait until workspace deletion to return ban success --------- Co-authored-by: Theodore Li <theo@sim.ai>
1 parent 2504bfb commit 712e58a

File tree

3 files changed

+136
-94
lines changed

3 files changed

+136
-94
lines changed

apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx

Lines changed: 99 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -227,123 +227,128 @@ export function Admin() {
227227
<div
228228
key={u.id}
229229
className={cn(
230-
'flex items-center gap-3 px-3 py-2 text-small',
230+
'flex flex-col gap-2 px-3 py-2 text-small',
231231
'border-[var(--border-secondary)] border-b last:border-b-0'
232232
)}
233233
>
234-
<span className='w-[200px] truncate text-[var(--text-primary)]'>
235-
{u.name || '—'}
236-
</span>
237-
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
238-
<span className='w-[80px]'>
239-
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
240-
</span>
241-
<span className='w-[80px]'>
242-
{u.banned ? (
243-
<Badge variant='red'>Banned</Badge>
244-
) : (
245-
<Badge variant='green'>Active</Badge>
246-
)}
247-
</span>
248-
<span className='flex w-[250px] justify-end gap-1'>
249-
{u.id !== session?.user?.id && (
250-
<>
251-
<Button
252-
variant='active'
253-
className='h-[28px] px-2 text-[12px]'
254-
onClick={() => handleImpersonate(u.id)}
255-
disabled={pendingUserIds.has(u.id)}
256-
>
257-
{impersonatingUserId === u.id ||
258-
(impersonateUser.isPending &&
259-
(impersonateUser.variables as { userId?: string } | undefined)
260-
?.userId === u.id)
261-
? 'Switching...'
262-
: 'Impersonate'}
263-
</Button>
264-
<Button
265-
variant='active'
266-
className='h-[28px] px-2 text-[12px]'
267-
onClick={() => {
268-
setUserRole.reset()
269-
setUserRole.mutate({
270-
userId: u.id,
271-
role: u.role === 'admin' ? 'user' : 'admin',
272-
})
273-
}}
274-
disabled={pendingUserIds.has(u.id)}
275-
>
276-
{u.role === 'admin' ? 'Demote' : 'Promote'}
277-
</Button>
278-
{u.banned ? (
234+
<div className='flex items-center gap-3'>
235+
<span className='w-[200px] truncate text-[var(--text-primary)]'>
236+
{u.name || '—'}
237+
</span>
238+
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
239+
<span className='w-[80px]'>
240+
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>
241+
{u.role || 'user'}
242+
</Badge>
243+
</span>
244+
<span className='w-[80px]'>
245+
{u.banned ? (
246+
<Badge variant='red'>Banned</Badge>
247+
) : (
248+
<Badge variant='green'>Active</Badge>
249+
)}
250+
</span>
251+
<span className='flex w-[250px] justify-end gap-1'>
252+
{u.id !== session?.user?.id && (
253+
<>
254+
<Button
255+
variant='active'
256+
className='h-[28px] px-2 text-[12px]'
257+
onClick={() => handleImpersonate(u.id)}
258+
disabled={pendingUserIds.has(u.id)}
259+
>
260+
{impersonatingUserId === u.id ||
261+
(impersonateUser.isPending &&
262+
(impersonateUser.variables as { userId?: string } | undefined)
263+
?.userId === u.id)
264+
? 'Switching...'
265+
: 'Impersonate'}
266+
</Button>
279267
<Button
280268
variant='active'
281-
className='h-[28px] px-2 text-caption'
269+
className='h-[28px] px-2 text-[12px]'
282270
onClick={() => {
283-
unbanUser.reset()
284-
unbanUser.mutate({ userId: u.id })
271+
setUserRole.reset()
272+
setUserRole.mutate({
273+
userId: u.id,
274+
role: u.role === 'admin' ? 'user' : 'admin',
275+
})
285276
}}
286277
disabled={pendingUserIds.has(u.id)}
287278
>
288-
Unban
279+
{u.role === 'admin' ? 'Demote' : 'Promote'}
289280
</Button>
290-
) : banUserId === u.id ? (
291-
<div className='flex gap-1'>
292-
<EmcnInput
293-
value={banReason}
294-
onChange={(e) => setBanReason(e.target.value)}
295-
placeholder='Reason (optional)'
296-
className='h-[28px] w-[120px] text-caption'
297-
/>
281+
{u.banned ? (
298282
<Button
299-
variant='primary'
283+
variant='active'
300284
className='h-[28px] px-2 text-caption'
301285
onClick={() => {
302-
banUser.reset()
303-
banUser.mutate(
304-
{
305-
userId: u.id,
306-
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
307-
},
308-
{
309-
onSuccess: () => {
310-
setBanUserId(null)
311-
setBanReason('')
312-
},
313-
}
314-
)
286+
unbanUser.reset()
287+
unbanUser.mutate({ userId: u.id })
315288
}}
316289
disabled={pendingUserIds.has(u.id)}
317290
>
318-
Confirm
291+
Unban
319292
</Button>
293+
) : (
320294
<Button
321295
variant='active'
322-
className='h-[28px] px-2 text-caption'
296+
className={cn(
297+
'h-[28px] px-2 text-caption',
298+
banUserId === u.id
299+
? 'text-[var(--text-primary)]'
300+
: 'text-[var(--text-error)]'
301+
)}
323302
onClick={() => {
324-
setBanUserId(null)
325-
setBanReason('')
303+
if (banUserId === u.id) {
304+
setBanUserId(null)
305+
setBanReason('')
306+
} else {
307+
setBanUserId(u.id)
308+
setBanReason('')
309+
}
326310
}}
311+
disabled={pendingUserIds.has(u.id)}
327312
>
328-
Cancel
313+
{banUserId === u.id ? 'Cancel' : 'Ban'}
329314
</Button>
330-
</div>
331-
) : (
332-
<Button
333-
variant='active'
334-
className='h-[28px] px-2 text-[var(--text-error)] text-caption'
335-
onClick={() => {
336-
setBanUserId(u.id)
337-
setBanReason('')
338-
}}
339-
disabled={pendingUserIds.has(u.id)}
340-
>
341-
Ban
342-
</Button>
343-
)}
344-
</>
345-
)}
346-
</span>
315+
)}
316+
</>
317+
)}
318+
</span>
319+
</div>
320+
{banUserId === u.id && !u.banned && (
321+
<div className='flex items-center gap-2 pl-[200px]'>
322+
<EmcnInput
323+
value={banReason}
324+
onChange={(e) => setBanReason(e.target.value)}
325+
placeholder='Reason (optional)'
326+
className='h-[28px] flex-1 text-caption'
327+
/>
328+
<Button
329+
variant='primary'
330+
className='h-[28px] px-3 text-caption'
331+
onClick={() => {
332+
banUser.reset()
333+
banUser.mutate(
334+
{
335+
userId: u.id,
336+
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
337+
},
338+
{
339+
onSuccess: () => {
340+
setBanUserId(null)
341+
setBanReason('')
342+
},
343+
}
344+
)
345+
}}
346+
disabled={pendingUserIds.has(u.id)}
347+
>
348+
Confirm Ban
349+
</Button>
350+
</div>
351+
)}
347352
</div>
348353
))}
349354
</div>

apps/sim/lib/auth/auth.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
8282
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
8383
import { captureServerEvent } from '@/lib/posthog/server'
8484
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
85+
import { disableUserResources } from '@/lib/workflows/lifecycle'
8586
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
8687
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
8788

@@ -243,6 +244,13 @@ export const auth = betterAuth({
243244
}
244245
},
245246
},
247+
update: {
248+
after: async (user) => {
249+
if (user.banned) {
250+
await disableUserResources(user.id)
251+
}
252+
},
253+
},
246254
},
247255
account: {
248256
create: {

apps/sim/lib/workflows/lifecycle.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { db } from '@sim/db'
22
import {
33
a2aAgent,
4+
apiKey,
45
chat,
56
form,
67
webhook,
@@ -9,12 +10,14 @@ import {
910
workflowFolder,
1011
workflowMcpTool,
1112
workflowSchedule,
13+
workspace,
1214
} from '@sim/db/schema'
1315
import { createLogger } from '@sim/logger'
1416
import { and, eq, inArray, isNull } from 'drizzle-orm'
1517
import { env } from '@/lib/core/config/env'
1618
import { getRedisClient } from '@/lib/core/config/redis'
1719
import { PlatformEvents } from '@/lib/core/telemetry'
20+
import { generateRequestId } from '@/lib/core/utils/request'
1821
import { mcpPubSub } from '@/lib/mcp/pubsub'
1922
import { getWorkflowById } from '@/lib/workflows/utils'
2023

@@ -379,3 +382,29 @@ export async function archiveWorkflowsByIdsInWorkspace(
379382
options
380383
)
381384
}
385+
386+
/**
387+
* Disables all resources owned by a banned user by archiving every workspace
388+
* they own (cascading to workflows, chats, forms, KBs, tables, files, etc.)
389+
* and deleting their personal API keys.
390+
*/
391+
export async function disableUserResources(userId: string): Promise<void> {
392+
const requestId = generateRequestId()
393+
logger.info(`[${requestId}] Disabling resources for banned user ${userId}`)
394+
395+
const { archiveWorkspace } = await import('@/lib/workspaces/lifecycle')
396+
397+
const ownedWorkspaces = await db
398+
.select({ id: workspace.id })
399+
.from(workspace)
400+
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt)))
401+
402+
await Promise.all([
403+
...ownedWorkspaces.map((w) => archiveWorkspace(w.id, { requestId })),
404+
db.delete(apiKey).where(eq(apiKey.userId, userId)),
405+
])
406+
407+
logger.info(
408+
`[${requestId}] Disabled resources for user ${userId}: archived ${ownedWorkspaces.length} workspaces, deleted API keys`
409+
)
410+
}

0 commit comments

Comments
 (0)