Skip to content

Commit 59182d5

Browse files
TheodoreSpeaksTheodore Li
andauthored
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>
1 parent b9926df commit 59182d5

File tree

7 files changed

+214
-11
lines changed

7 files changed

+214
-11
lines changed

apps/sim/app/_shell/providers/session-provider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type AppSession = {
2121
id?: string
2222
userId?: string
2323
activeOrganizationId?: string
24+
impersonatedBy?: string | null
2425
}
2526
} | null
2627

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { Banner } from '@/components/emcn'
5+
import { useSession } from '@/lib/auth/auth-client'
6+
import { useStopImpersonating } from '@/hooks/queries/admin-users'
7+
8+
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
9+
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
10+
}
11+
12+
export function ImpersonationBanner() {
13+
const { data: session, isPending } = useSession()
14+
const stopImpersonating = useStopImpersonating()
15+
const [isRedirecting, setIsRedirecting] = useState(false)
16+
const userLabel = session?.user?.name || 'this user'
17+
const userEmail = session?.user?.email
18+
19+
if (isPending || !session?.session?.impersonatedBy) {
20+
return null
21+
}
22+
23+
return (
24+
<Banner
25+
variant='destructive'
26+
text={getImpersonationBannerText(userLabel, userEmail)}
27+
textClassName='text-red-700 dark:text-red-300'
28+
actionLabel={
29+
stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating'
30+
}
31+
actionVariant='destructive'
32+
actionDisabled={stopImpersonating.isPending || isRedirecting}
33+
onAction={() =>
34+
stopImpersonating.mutate(undefined, {
35+
onError: () => {
36+
setIsRedirecting(false)
37+
},
38+
onSuccess: () => {
39+
setIsRedirecting(true)
40+
window.location.assign('/workspace')
41+
},
42+
})
43+
}
44+
/>
45+
)
46+
}

apps/sim/app/workspace/[workspaceId]/layout.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ToastProvider } from '@/components/emcn'
22
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
3+
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
34
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
45
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
56
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -12,14 +13,17 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
1213
<SettingsLoader />
1314
<ProviderModelsLoader />
1415
<GlobalCommandsProvider>
15-
<div className='flex h-screen w-full bg-[var(--surface-1)]'>
16+
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
17+
<ImpersonationBanner />
1618
<WorkspacePermissionsProvider>
17-
<div className='shrink-0' suppressHydrationWarning>
18-
<Sidebar />
19-
</div>
20-
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
21-
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
22-
{children}
19+
<div className='flex min-h-0 flex-1'>
20+
<div className='shrink-0' suppressHydrationWarning>
21+
<Sidebar />
22+
</div>
23+
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
24+
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
25+
{children}
26+
</div>
2327
</div>
2428
</div>
2529
<NavTour />

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

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
88
import {
99
useAdminUsers,
1010
useBanUser,
11+
useImpersonateUser,
1112
useSetUserRole,
1213
useUnbanUser,
1314
} from '@/hooks/queries/admin-users'
@@ -28,13 +29,16 @@ export function Admin() {
2829
const setUserRole = useSetUserRole()
2930
const banUser = useBanUser()
3031
const unbanUser = useUnbanUser()
32+
const impersonateUser = useImpersonateUser()
3133

3234
const [workflowId, setWorkflowId] = useState('')
3335
const [usersOffset, setUsersOffset] = useState(0)
3436
const [searchInput, setSearchInput] = useState('')
3537
const [searchQuery, setSearchQuery] = useState('')
3638
const [banUserId, setBanUserId] = useState<string | null>(null)
3739
const [banReason, setBanReason] = useState('')
40+
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(null)
41+
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
3842

3943
const {
4044
data: usersData,
@@ -67,6 +71,29 @@ export function Admin() {
6771
)
6872
}
6973

74+
const handleImpersonate = (userId: string) => {
75+
setImpersonationGuardError(null)
76+
if (session?.user?.role !== 'admin') {
77+
setImpersonatingUserId(null)
78+
setImpersonationGuardError('Only admins can impersonate users.')
79+
return
80+
}
81+
82+
setImpersonatingUserId(userId)
83+
impersonateUser.reset()
84+
impersonateUser.mutate(
85+
{ userId },
86+
{
87+
onError: () => {
88+
setImpersonatingUserId(null)
89+
},
90+
onSuccess: () => {
91+
window.location.assign('/workspace')
92+
},
93+
}
94+
)
95+
}
96+
7097
const pendingUserIds = useMemo(() => {
7198
const ids = new Set<string>()
7299
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
@@ -75,6 +102,9 @@ export function Admin() {
75102
ids.add((banUser.variables as { userId: string }).userId)
76103
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
77104
ids.add((unbanUser.variables as { userId: string }).userId)
105+
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
106+
ids.add((impersonateUser.variables as { userId: string }).userId)
107+
if (impersonatingUserId) ids.add(impersonatingUserId)
78108
return ids
79109
}, [
80110
setUserRole.isPending,
@@ -83,6 +113,9 @@ export function Admin() {
83113
banUser.variables,
84114
unbanUser.isPending,
85115
unbanUser.variables,
116+
impersonateUser.isPending,
117+
impersonateUser.variables,
118+
impersonatingUserId,
86119
])
87120
return (
88121
<div className='flex h-full flex-col gap-[24px]'>
@@ -152,9 +185,15 @@ export function Admin() {
152185
</p>
153186
)}
154187

155-
{(setUserRole.error || banUser.error || unbanUser.error) && (
188+
{(setUserRole.error ||
189+
banUser.error ||
190+
unbanUser.error ||
191+
impersonateUser.error ||
192+
impersonationGuardError) && (
156193
<p className='text-[13px] text-[var(--text-error)]'>
157-
{(setUserRole.error || banUser.error || unbanUser.error)?.message ??
194+
{impersonationGuardError ||
195+
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
196+
?.message ||
158197
'Action failed. Please try again.'}
159198
</p>
160199
)}
@@ -175,7 +214,7 @@ export function Admin() {
175214
<span className='flex-1'>Email</span>
176215
<span className='w-[80px]'>Role</span>
177216
<span className='w-[80px]'>Status</span>
178-
<span className='w-[180px] text-right'>Actions</span>
217+
<span className='w-[250px] text-right'>Actions</span>
179218
</div>
180219

181220
{usersData.users.length === 0 && (
@@ -206,9 +245,22 @@ export function Admin() {
206245
<Badge variant='green'>Active</Badge>
207246
)}
208247
</span>
209-
<span className='flex w-[180px] justify-end gap-[4px]'>
248+
<span className='flex w-[250px] justify-end gap-[4px]'>
210249
{u.id !== session?.user?.id && (
211250
<>
251+
<Button
252+
variant='active'
253+
className='h-[28px] px-[8px] 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>
212264
<Button
213265
variant='active'
214266
className='h-[28px] px-[8px] text-[12px]'
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use client'
2+
3+
import type { HTMLAttributes, ReactNode } from 'react'
4+
import { cva, type VariantProps } from 'class-variance-authority'
5+
import { Button, type ButtonProps } from '@/components/emcn/components/button/button'
6+
import { cn } from '@/lib/core/utils/cn'
7+
8+
const bannerVariants = cva('shrink-0 px-[24px] py-[10px]', {
9+
variants: {
10+
variant: {
11+
default: 'bg-[var(--surface-active)]',
12+
destructive: 'bg-red-50 dark:bg-red-950/30',
13+
},
14+
},
15+
defaultVariants: {
16+
variant: 'default',
17+
},
18+
})
19+
20+
export interface BannerProps
21+
extends HTMLAttributes<HTMLDivElement>,
22+
VariantProps<typeof bannerVariants> {
23+
actionClassName?: string
24+
actionDisabled?: boolean
25+
actionLabel?: ReactNode
26+
actionProps?: Omit<ButtonProps, 'children' | 'className' | 'disabled' | 'onClick' | 'variant'>
27+
actionVariant?: ButtonProps['variant']
28+
children?: ReactNode
29+
contentClassName?: string
30+
onAction?: () => void
31+
text?: ReactNode
32+
textClassName?: string
33+
}
34+
35+
export function Banner({
36+
actionClassName,
37+
actionDisabled,
38+
actionLabel,
39+
actionProps,
40+
actionVariant = 'default',
41+
children,
42+
className,
43+
contentClassName,
44+
onAction,
45+
text,
46+
textClassName,
47+
variant,
48+
...props
49+
}: BannerProps) {
50+
return (
51+
<div className={cn(bannerVariants({ variant }), className)} {...props}>
52+
{children ?? (
53+
<div
54+
className={cn(
55+
'mx-auto flex max-w-[1400px] items-center justify-between gap-[12px]',
56+
contentClassName
57+
)}
58+
>
59+
<p className={cn('text-[13px]', textClassName)}>{text}</p>
60+
{actionLabel ? (
61+
<Button
62+
variant={actionVariant}
63+
className={cn('h-[28px] shrink-0 px-[8px] text-[12px]', actionClassName)}
64+
onClick={onAction}
65+
disabled={actionDisabled}
66+
{...actionProps}
67+
>
68+
{actionLabel}
69+
</Button>
70+
) : null}
71+
</div>
72+
)}
73+
</div>
74+
)
75+
}

apps/sim/components/emcn/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {
77
avatarVariants,
88
} from './avatar/avatar'
99
export { Badge } from './badge/badge'
10+
export { Banner, type BannerProps } from './banner/banner'
1011
export { Breadcrumb, type BreadcrumbItem, type BreadcrumbProps } from './breadcrumb/breadcrumb'
1112
export { Button, type ButtonProps, buttonVariants } from './button/button'
1213
export {

apps/sim/hooks/queries/admin-users.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,27 @@ export function useUnbanUser() {
133133
},
134134
})
135135
}
136+
137+
export function useImpersonateUser() {
138+
return useMutation({
139+
mutationFn: async ({ userId }: { userId: string }) => {
140+
const result = await client.admin.impersonateUser({ userId })
141+
return result
142+
},
143+
onError: (err) => {
144+
logger.error('Failed to impersonate user', err)
145+
},
146+
})
147+
}
148+
149+
export function useStopImpersonating() {
150+
return useMutation({
151+
mutationFn: async () => {
152+
const result = await client.admin.stopImpersonating()
153+
return result
154+
},
155+
onError: (err) => {
156+
logger.error('Failed to stop impersonating', err)
157+
},
158+
})
159+
}

0 commit comments

Comments
 (0)