mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(admin): Add assume user capability (#3742)
* Allow admin users to assume user sessions * Add explicit role check * Fix lint * Remove admin panel when impersonating * Fix lint --------- Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
co-authored by
Theodore Li
parent
b9926df8e0
commit
59182d5db2
@@ -21,6 +21,7 @@ export type AppSession = {
|
||||
id?: string
|
||||
userId?: string
|
||||
activeOrganizationId?: string
|
||||
impersonatedBy?: string | null
|
||||
}
|
||||
} | null
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Banner } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useStopImpersonating } from '@/hooks/queries/admin-users'
|
||||
|
||||
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
|
||||
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
|
||||
}
|
||||
|
||||
export function ImpersonationBanner() {
|
||||
const { data: session, isPending } = useSession()
|
||||
const stopImpersonating = useStopImpersonating()
|
||||
const [isRedirecting, setIsRedirecting] = useState(false)
|
||||
const userLabel = session?.user?.name || 'this user'
|
||||
const userEmail = session?.user?.email
|
||||
|
||||
if (isPending || !session?.session?.impersonatedBy) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner
|
||||
variant='destructive'
|
||||
text={getImpersonationBannerText(userLabel, userEmail)}
|
||||
textClassName='text-red-700 dark:text-red-300'
|
||||
actionLabel={
|
||||
stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating'
|
||||
}
|
||||
actionVariant='destructive'
|
||||
actionDisabled={stopImpersonating.isPending || isRedirecting}
|
||||
onAction={() =>
|
||||
stopImpersonating.mutate(undefined, {
|
||||
onError: () => {
|
||||
setIsRedirecting(false)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsRedirecting(true)
|
||||
window.location.assign('/workspace')
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ToastProvider } from '@/components/emcn'
|
||||
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
|
||||
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
|
||||
@@ -12,14 +13,17 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<div className='flex h-screen w-full bg-[var(--surface-1)]'>
|
||||
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
|
||||
<ImpersonationBanner />
|
||||
<WorkspacePermissionsProvider>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
<div className='flex min-h-0 flex-1'>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
|
||||
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavTour />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
useAdminUsers,
|
||||
useBanUser,
|
||||
useImpersonateUser,
|
||||
useSetUserRole,
|
||||
useUnbanUser,
|
||||
} from '@/hooks/queries/admin-users'
|
||||
@@ -28,6 +29,7 @@ export function Admin() {
|
||||
const setUserRole = useSetUserRole()
|
||||
const banUser = useBanUser()
|
||||
const unbanUser = useUnbanUser()
|
||||
const impersonateUser = useImpersonateUser()
|
||||
|
||||
const [workflowId, setWorkflowId] = useState('')
|
||||
const [usersOffset, setUsersOffset] = useState(0)
|
||||
@@ -35,6 +37,8 @@ export function Admin() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [banUserId, setBanUserId] = useState<string | null>(null)
|
||||
const [banReason, setBanReason] = useState('')
|
||||
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(null)
|
||||
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
data: usersData,
|
||||
@@ -67,6 +71,29 @@ export function Admin() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleImpersonate = (userId: string) => {
|
||||
setImpersonationGuardError(null)
|
||||
if (session?.user?.role !== 'admin') {
|
||||
setImpersonatingUserId(null)
|
||||
setImpersonationGuardError('Only admins can impersonate users.')
|
||||
return
|
||||
}
|
||||
|
||||
setImpersonatingUserId(userId)
|
||||
impersonateUser.reset()
|
||||
impersonateUser.mutate(
|
||||
{ userId },
|
||||
{
|
||||
onError: () => {
|
||||
setImpersonatingUserId(null)
|
||||
},
|
||||
onSuccess: () => {
|
||||
window.location.assign('/workspace')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const pendingUserIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
|
||||
@@ -75,6 +102,9 @@ export function Admin() {
|
||||
ids.add((banUser.variables as { userId: string }).userId)
|
||||
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
|
||||
ids.add((unbanUser.variables as { userId: string }).userId)
|
||||
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
|
||||
ids.add((impersonateUser.variables as { userId: string }).userId)
|
||||
if (impersonatingUserId) ids.add(impersonatingUserId)
|
||||
return ids
|
||||
}, [
|
||||
setUserRole.isPending,
|
||||
@@ -83,6 +113,9 @@ export function Admin() {
|
||||
banUser.variables,
|
||||
unbanUser.isPending,
|
||||
unbanUser.variables,
|
||||
impersonateUser.isPending,
|
||||
impersonateUser.variables,
|
||||
impersonatingUserId,
|
||||
])
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[24px]'>
|
||||
@@ -152,9 +185,15 @@ export function Admin() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(setUserRole.error || banUser.error || unbanUser.error) && (
|
||||
{(setUserRole.error ||
|
||||
banUser.error ||
|
||||
unbanUser.error ||
|
||||
impersonateUser.error ||
|
||||
impersonationGuardError) && (
|
||||
<p className='text-[13px] text-[var(--text-error)]'>
|
||||
{(setUserRole.error || banUser.error || unbanUser.error)?.message ??
|
||||
{impersonationGuardError ||
|
||||
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
|
||||
?.message ||
|
||||
'Action failed. Please try again.'}
|
||||
</p>
|
||||
)}
|
||||
@@ -175,7 +214,7 @@ export function Admin() {
|
||||
<span className='flex-1'>Email</span>
|
||||
<span className='w-[80px]'>Role</span>
|
||||
<span className='w-[80px]'>Status</span>
|
||||
<span className='w-[180px] text-right'>Actions</span>
|
||||
<span className='w-[250px] text-right'>Actions</span>
|
||||
</div>
|
||||
|
||||
{usersData.users.length === 0 && (
|
||||
@@ -206,9 +245,22 @@ export function Admin() {
|
||||
<Badge variant='green'>Active</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className='flex w-[180px] justify-end gap-[4px]'>
|
||||
<span className='flex w-[250px] justify-end gap-[4px]'>
|
||||
{u.id !== session?.user?.id && (
|
||||
<>
|
||||
<Button
|
||||
variant='active'
|
||||
className='h-[28px] px-[8px] 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-[8px] text-[12px]'
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Button, type ButtonProps } from '@/components/emcn/components/button/button'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const bannerVariants = cva('shrink-0 px-[24px] py-[10px]', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-[var(--surface-active)]',
|
||||
destructive: 'bg-red-50 dark:bg-red-950/30',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
export interface BannerProps
|
||||
extends HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof bannerVariants> {
|
||||
actionClassName?: string
|
||||
actionDisabled?: boolean
|
||||
actionLabel?: ReactNode
|
||||
actionProps?: Omit<ButtonProps, 'children' | 'className' | 'disabled' | 'onClick' | 'variant'>
|
||||
actionVariant?: ButtonProps['variant']
|
||||
children?: ReactNode
|
||||
contentClassName?: string
|
||||
onAction?: () => void
|
||||
text?: ReactNode
|
||||
textClassName?: string
|
||||
}
|
||||
|
||||
export function Banner({
|
||||
actionClassName,
|
||||
actionDisabled,
|
||||
actionLabel,
|
||||
actionProps,
|
||||
actionVariant = 'default',
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
onAction,
|
||||
text,
|
||||
textClassName,
|
||||
variant,
|
||||
...props
|
||||
}: BannerProps) {
|
||||
return (
|
||||
<div className={cn(bannerVariants({ variant }), className)} {...props}>
|
||||
{children ?? (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex max-w-[1400px] items-center justify-between gap-[12px]',
|
||||
contentClassName
|
||||
)}
|
||||
>
|
||||
<p className={cn('text-[13px]', textClassName)}>{text}</p>
|
||||
{actionLabel ? (
|
||||
<Button
|
||||
variant={actionVariant}
|
||||
className={cn('h-[28px] shrink-0 px-[8px] text-[12px]', actionClassName)}
|
||||
onClick={onAction}
|
||||
disabled={actionDisabled}
|
||||
{...actionProps}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
avatarVariants,
|
||||
} from './avatar/avatar'
|
||||
export { Badge } from './badge/badge'
|
||||
export { Banner, type BannerProps } from './banner/banner'
|
||||
export { Breadcrumb, type BreadcrumbItem, type BreadcrumbProps } from './breadcrumb/breadcrumb'
|
||||
export { Button, type ButtonProps, buttonVariants } from './button/button'
|
||||
export {
|
||||
|
||||
@@ -133,3 +133,27 @@ export function useUnbanUser() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useImpersonateUser() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId }: { userId: string }) => {
|
||||
const result = await client.admin.impersonateUser({ userId })
|
||||
return result
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Failed to impersonate user', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopImpersonating() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await client.admin.stopImpersonating()
|
||||
return result
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Failed to stop impersonating', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user