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>
This commit is contained in:
Theodore Li
2026-04-07 20:21:43 -07:00
committed by GitHub
parent 2504bfbaf8
commit 712e58a7b5
3 changed files with 143 additions and 101 deletions

View File

@@ -227,123 +227,128 @@ export function Admin() {
<div
key={u.id}
className={cn(
'flex items-center gap-3 px-3 py-2 text-small',
'flex flex-col gap-2 px-3 py-2 text-small',
'border-[var(--border-secondary)] border-b last:border-b-0'
)}
>
<span className='w-[200px] truncate text-[var(--text-primary)]'>
{u.name || '—'}
</span>
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
<span className='w-[80px]'>
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
</span>
<span className='w-[80px]'>
{u.banned ? (
<Badge variant='red'>Banned</Badge>
) : (
<Badge variant='green'>Active</Badge>
)}
</span>
<span className='flex w-[250px] justify-end gap-1'>
{u.id !== session?.user?.id && (
<>
<Button
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => handleImpersonate(u.id)}
disabled={pendingUserIds.has(u.id)}
>
{impersonatingUserId === u.id ||
(impersonateUser.isPending &&
(impersonateUser.variables as { userId?: string } | undefined)
?.userId === u.id)
? 'Switching...'
: 'Impersonate'}
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => {
setUserRole.reset()
setUserRole.mutate({
userId: u.id,
role: u.role === 'admin' ? 'user' : 'admin',
})
}}
disabled={pendingUserIds.has(u.id)}
>
{u.role === 'admin' ? 'Demote' : 'Promote'}
</Button>
{u.banned ? (
<div className='flex items-center gap-3'>
<span className='w-[200px] truncate text-[var(--text-primary)]'>
{u.name || '—'}
</span>
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
<span className='w-[80px]'>
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>
{u.role || 'user'}
</Badge>
</span>
<span className='w-[80px]'>
{u.banned ? (
<Badge variant='red'>Banned</Badge>
) : (
<Badge variant='green'>Active</Badge>
)}
</span>
<span className='flex w-[250px] justify-end gap-1'>
{u.id !== session?.user?.id && (
<>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
className='h-[28px] px-2 text-[12px]'
onClick={() => handleImpersonate(u.id)}
disabled={pendingUserIds.has(u.id)}
>
{impersonatingUserId === u.id ||
(impersonateUser.isPending &&
(impersonateUser.variables as { userId?: string } | undefined)
?.userId === u.id)
? 'Switching...'
: 'Impersonate'}
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-[12px]'
onClick={() => {
unbanUser.reset()
unbanUser.mutate({ userId: u.id })
setUserRole.reset()
setUserRole.mutate({
userId: u.id,
role: u.role === 'admin' ? 'user' : 'admin',
})
}}
disabled={pendingUserIds.has(u.id)}
>
Unban
{u.role === 'admin' ? 'Demote' : 'Promote'}
</Button>
) : banUserId === u.id ? (
<div className='flex gap-1'>
<EmcnInput
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder='Reason (optional)'
className='h-[28px] w-[120px] text-caption'
/>
<Button
variant='primary'
className='h-[28px] px-2 text-caption'
onClick={() => {
banUser.reset()
banUser.mutate(
{
userId: u.id,
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
},
{
onSuccess: () => {
setBanUserId(null)
setBanReason('')
},
}
)
}}
disabled={pendingUserIds.has(u.id)}
>
Confirm
</Button>
{u.banned ? (
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() => {
unbanUser.reset()
unbanUser.mutate({ userId: u.id })
}}
disabled={pendingUserIds.has(u.id)}
>
Unban
</Button>
) : (
<Button
variant='active'
className={cn(
'h-[28px] px-2 text-caption',
banUserId === u.id
? 'text-[var(--text-primary)]'
: 'text-[var(--text-error)]'
)}
onClick={() => {
if (banUserId === u.id) {
setBanUserId(null)
setBanReason('')
} else {
setBanUserId(u.id)
setBanReason('')
}
}}
disabled={pendingUserIds.has(u.id)}
>
{banUserId === u.id ? 'Cancel' : 'Ban'}
</Button>
)}
</>
)}
</span>
</div>
{banUserId === u.id && !u.banned && (
<div className='flex items-center gap-2 pl-[200px]'>
<EmcnInput
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder='Reason (optional)'
className='h-[28px] flex-1 text-caption'
/>
<Button
variant='primary'
className='h-[28px] px-3 text-caption'
onClick={() => {
banUser.reset()
banUser.mutate(
{
userId: u.id,
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
},
{
onSuccess: () => {
setBanUserId(null)
setBanReason('')
}}
>
Cancel
</Button>
</div>
) : (
<Button
variant='active'
className='h-[28px] px-2 text-[var(--text-error)] text-caption'
onClick={() => {
setBanUserId(u.id)
setBanReason('')
}}
disabled={pendingUserIds.has(u.id)}
>
Ban
</Button>
)}
</>
)}
</span>
},
}
)
}}
disabled={pendingUserIds.has(u.id)}
>
Confirm Ban
</Button>
</div>
)}
</div>
))}
</div>

View File

@@ -82,6 +82,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
import { captureServerEvent } from '@/lib/posthog/server'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { disableUserResources } from '@/lib/workflows/lifecycle'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
@@ -243,6 +244,13 @@ export const auth = betterAuth({
}
},
},
update: {
after: async (user) => {
if (user.banned) {
await disableUserResources(user.id)
}
},
},
},
account: {
create: {

View File

@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import {
a2aAgent,
apiKey,
chat,
form,
webhook,
@@ -9,12 +10,14 @@ import {
workflowFolder,
workflowMcpTool,
workflowSchedule,
workspace,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { env } from '@/lib/core/config/env'
import { getRedisClient } from '@/lib/core/config/redis'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { getWorkflowById } from '@/lib/workflows/utils'
@@ -379,3 +382,29 @@ export async function archiveWorkflowsByIdsInWorkspace(
options
)
}
/**
* Disables all resources owned by a banned user by archiving every workspace
* they own (cascading to workflows, chats, forms, KBs, tables, files, etc.)
* and deleting their personal API keys.
*/
export async function disableUserResources(userId: string): Promise<void> {
const requestId = generateRequestId()
logger.info(`[${requestId}] Disabling resources for banned user ${userId}`)
const { archiveWorkspace } = await import('@/lib/workspaces/lifecycle')
const ownedWorkspaces = await db
.select({ id: workspace.id })
.from(workspace)
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt)))
await Promise.all([
...ownedWorkspaces.map((w) => archiveWorkspace(w.id, { requestId })),
db.delete(apiKey).where(eq(apiKey.userId, userId)),
])
logger.info(
`[${requestId}] Disabled resources for user ${userId}: archived ${ownedWorkspaces.length} workspaces, deleted API keys`
)
}