Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/_shell/providers/session-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type AppSession = {
id?: string
userId?: string
activeOrganizationId?: string
impersonatedBy?: string | null
}
} | null

Expand Down
46 changes: 46 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/impersonation-banner.tsx
Original file line number Diff line number Diff line change
@@ -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')
},
})
}
/>
)
}
18 changes: 11 additions & 7 deletions apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
import {
useAdminUsers,
useBanUser,
useImpersonateUser,
useSetUserRole,
useUnbanUser,
} from '@/hooks/queries/admin-users'
Expand All @@ -28,13 +29,16 @@ export function Admin() {
const setUserRole = useSetUserRole()
const banUser = useBanUser()
const unbanUser = useUnbanUser()
const impersonateUser = useImpersonateUser()

const [workflowId, setWorkflowId] = useState('')
const [usersOffset, setUsersOffset] = useState(0)
const [searchInput, setSearchInput] = useState('')
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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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]'>
Expand Down Expand Up @@ -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>
)}
Expand All @@ -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 && (
Expand Down Expand Up @@ -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]'
Expand Down
75 changes: 75 additions & 0 deletions apps/sim/components/emcn/components/banner/banner.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
1 change: 1 addition & 0 deletions apps/sim/components/emcn/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions apps/sim/hooks/queries/admin-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
})
}
Loading