mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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:
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user