Skip to content

Commit d209682

Browse files
author
Theodore Li
committed
Allow admin users to assume user sessions
1 parent b09a073 commit d209682

File tree

8 files changed

+147
-14
lines changed

8 files changed

+147
-14
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

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

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,66 @@
1-
import { ToastProvider } from '@/components/emcn'
1+
import { Banner, Button, ToastProvider } from '@/components/emcn'
22
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
33
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
44
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
55
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
6+
import { useSession } from '@/lib/auth/auth-client'
7+
import { useStopImpersonating } from '@/hooks/queries/admin-users'
68
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
79

10+
function ImpersonationBanner() {
11+
const { data: session, isPending } = useSession()
12+
const stopImpersonating = useStopImpersonating()
13+
const userLabel = session?.user?.name || 'this user'
14+
const userEmail = session?.user?.email
15+
16+
if (isPending || !session?.session?.impersonatedBy) {
17+
return null
18+
}
19+
20+
return (
21+
<Banner variant='destructive'>
22+
<div className='mx-auto flex max-w-[1400px] items-center justify-between gap-[12px]'>
23+
<p className='text-[13px] text-red-700 dark:text-red-300'>
24+
Impersonating {userLabel}
25+
{userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch
26+
back.
27+
</p>
28+
<Button
29+
variant='destructive'
30+
className='h-[28px] shrink-0 px-[8px] text-[12px]'
31+
onClick={() =>
32+
stopImpersonating.mutate(undefined, {
33+
onSuccess: () => {
34+
window.location.assign('/workspace')
35+
},
36+
})
37+
}
38+
disabled={stopImpersonating.isPending}
39+
>
40+
{stopImpersonating.isPending ? 'Returning...' : 'Stop impersonating'}
41+
</Button>
42+
</div>
43+
</Banner>
44+
)
45+
}
46+
847
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
948
return (
1049
<ToastProvider>
1150
<SettingsLoader />
1251
<ProviderModelsLoader />
1352
<GlobalCommandsProvider>
14-
<div className='flex h-screen w-full bg-[var(--surface-1)]'>
53+
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
54+
<ImpersonationBanner />
1555
<WorkspacePermissionsProvider>
16-
<div className='shrink-0' suppressHydrationWarning>
17-
<Sidebar />
18-
</div>
19-
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
20-
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
21-
{children}
56+
<div className='flex min-h-0 flex-1'>
57+
<div className='shrink-0' suppressHydrationWarning>
58+
<Sidebar />
59+
</div>
60+
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
61+
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
62+
{children}
63+
</div>
2264
</div>
2365
</div>
2466
</WorkspacePermissionsProvider>

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,11 @@ export function SettingsPage({ section }: SettingsPageProps) {
161161
const { data: session, isPending: sessionLoading } = useSession()
162162

163163
const isAdminRole = session?.user?.role === 'admin'
164+
const isImpersonating = Boolean(session?.session?.impersonatedBy)
164165
const effectiveSection =
165166
!isBillingEnabled && (section === 'subscription' || section === 'team')
166167
? 'general'
167-
: section === 'admin' && !sessionLoading && !isAdminRole
168+
: section === 'admin' && !sessionLoading && !isAdminRole && !isImpersonating
168169
? 'general'
169170
: section
170171

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

Lines changed: 35 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,6 +29,7 @@ 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)
@@ -67,6 +69,18 @@ export function Admin() {
6769
)
6870
}
6971

72+
const handleImpersonate = (userId: string) => {
73+
impersonateUser.reset()
74+
impersonateUser.mutate(
75+
{ userId },
76+
{
77+
onSuccess: () => {
78+
window.location.assign('/workspace')
79+
},
80+
}
81+
)
82+
}
83+
7084
const pendingUserIds = useMemo(() => {
7185
const ids = new Set<string>()
7286
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
@@ -75,6 +89,8 @@ export function Admin() {
7589
ids.add((banUser.variables as { userId: string }).userId)
7690
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
7791
ids.add((unbanUser.variables as { userId: string }).userId)
92+
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
93+
ids.add((impersonateUser.variables as { userId: string }).userId)
7894
return ids
7995
}, [
8096
setUserRole.isPending,
@@ -83,6 +99,8 @@ export function Admin() {
8399
banUser.variables,
84100
unbanUser.isPending,
85101
unbanUser.variables,
102+
impersonateUser.isPending,
103+
impersonateUser.variables,
86104
])
87105
return (
88106
<div className='flex h-full flex-col gap-[24px]'>
@@ -152,9 +170,10 @@ export function Admin() {
152170
</p>
153171
)}
154172

155-
{(setUserRole.error || banUser.error || unbanUser.error) && (
173+
{(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) && (
156174
<p className='text-[13px] text-[var(--text-error)]'>
157-
{(setUserRole.error || banUser.error || unbanUser.error)?.message ??
175+
{(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
176+
?.message ??
158177
'Action failed. Please try again.'}
159178
</p>
160179
)}
@@ -175,7 +194,7 @@ export function Admin() {
175194
<span className='flex-1'>Email</span>
176195
<span className='w-[80px]'>Role</span>
177196
<span className='w-[80px]'>Status</span>
178-
<span className='w-[180px] text-right'>Actions</span>
197+
<span className='w-[250px] text-right'>Actions</span>
179198
</div>
180199

181200
{usersData.users.length === 0 && (
@@ -206,9 +225,21 @@ export function Admin() {
206225
<Badge variant='green'>Active</Badge>
207226
)}
208227
</span>
209-
<span className='flex w-[180px] justify-end gap-[4px]'>
228+
<span className='flex w-[250px] justify-end gap-[4px]'>
210229
{u.id !== session?.user?.id && (
211230
<>
231+
<Button
232+
variant='active'
233+
className='h-[28px] px-[8px] text-[12px]'
234+
onClick={() => handleImpersonate(u.id)}
235+
disabled={pendingUserIds.has(u.id)}
236+
>
237+
{impersonateUser.isPending &&
238+
(impersonateUser.variables as { userId?: string } | undefined)?.userId ===
239+
u.id
240+
? 'Switching...'
241+
: 'Impersonate'}
242+
</Button>
212243
<Button
213244
variant='active'
214245
className='h-[28px] px-[8px] text-[12px]'

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export function SettingsSidebar({
6464
const hasEnterprisePlan = subscriptionStatus.isEnterprise
6565

6666
const isSuperUser = session?.user?.role === 'admin'
67+
const isImpersonating = Boolean(session?.session?.impersonatedBy)
6768

6869
const isSSOProviderOwner = useMemo(() => {
6970
if (isHosted) return null
@@ -121,7 +122,7 @@ export function SettingsSidebar({
121122
return false
122123
}
123124

124-
if (item.requiresAdminRole && !isSuperUser) {
125+
if (item.requiresAdminRole && !isSuperUser && !isImpersonating) {
125126
return false
126127
}
127128

@@ -135,6 +136,7 @@ export function SettingsSidebar({
135136
ssoProvidersData?.providers?.length,
136137
permissionConfig,
137138
isSuperUser,
139+
isImpersonating,
138140
generalSettings?.superUserModeEnabled,
139141
])
140142

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client'
2+
3+
import type { HTMLAttributes, ReactNode } from 'react'
4+
import { cva, type VariantProps } from 'class-variance-authority'
5+
import { cn } from '@/lib/core/utils/cn'
6+
7+
const bannerVariants = cva('shrink-0 px-[24px] py-[10px]', {
8+
variants: {
9+
variant: {
10+
default: 'bg-[var(--surface-active)]',
11+
destructive: 'bg-red-50 dark:bg-red-950/30',
12+
},
13+
},
14+
defaultVariants: {
15+
variant: 'default',
16+
},
17+
})
18+
19+
export interface BannerProps
20+
extends HTMLAttributes<HTMLDivElement>,
21+
VariantProps<typeof bannerVariants> {
22+
children: ReactNode
23+
}
24+
25+
export function Banner({ className, variant, children, ...props }: BannerProps) {
26+
return (
27+
<div className={cn(bannerVariants({ variant }), className)} {...props}>
28+
{children}
29+
</div>
30+
)
31+
}

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

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

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)