From d2096827923700eb96f44890cde22fedbaad8938 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 24 Mar 2026 10:51:23 -0700 Subject: [PATCH 1/5] Allow admin users to assume user sessions --- .../app/_shell/providers/session-provider.tsx | 1 + .../app/workspace/[workspaceId]/layout.tsx | 58 ++++++++++++++++--- .../settings/[section]/settings.tsx | 3 +- .../settings/components/admin/admin.tsx | 39 +++++++++++-- .../settings-sidebar/settings-sidebar.tsx | 4 +- .../emcn/components/banner/banner.tsx | 31 ++++++++++ apps/sim/components/emcn/components/index.ts | 1 + apps/sim/hooks/queries/admin-users.ts | 24 ++++++++ 8 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 apps/sim/components/emcn/components/banner/banner.tsx diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index 7678f8b100b..b2fe1fb43f2 100644 --- a/apps/sim/app/_shell/providers/session-provider.tsx +++ b/apps/sim/app/_shell/providers/session-provider.tsx @@ -21,6 +21,7 @@ export type AppSession = { id?: string userId?: string activeOrganizationId?: string + impersonatedBy?: string | null } } | null diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 45a92298f32..508552e1cde 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -1,24 +1,66 @@ -import { ToastProvider } from '@/components/emcn' +import { Banner, Button, ToastProvider } from '@/components/emcn' 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' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useSession } from '@/lib/auth/auth-client' +import { useStopImpersonating } from '@/hooks/queries/admin-users' import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' +function ImpersonationBanner() { + const { data: session, isPending } = useSession() + const stopImpersonating = useStopImpersonating() + const userLabel = session?.user?.name || 'this user' + const userEmail = session?.user?.email + + if (isPending || !session?.session?.impersonatedBy) { + return null + } + + return ( + +
+

+ Impersonating {userLabel} + {userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch + back. +

+ +
+
+ ) +} + export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { return ( -
+
+ -
- -
-
-
- {children} +
+
+ +
+
+
+ {children} +
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index fd01097fde9..ed5edd31352 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -161,10 +161,11 @@ export function SettingsPage({ section }: SettingsPageProps) { const { data: session, isPending: sessionLoading } = useSession() const isAdminRole = session?.user?.role === 'admin' + const isImpersonating = Boolean(session?.session?.impersonatedBy) const effectiveSection = !isBillingEnabled && (section === 'subscription' || section === 'team') ? 'general' - : section === 'admin' && !sessionLoading && !isAdminRole + : section === 'admin' && !sessionLoading && !isAdminRole && !isImpersonating ? 'general' : section diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 5161bb3c5d9..01a000fd26b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -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) @@ -67,6 +69,18 @@ export function Admin() { ) } + const handleImpersonate = (userId: string) => { + impersonateUser.reset() + impersonateUser.mutate( + { userId }, + { + onSuccess: () => { + window.location.assign('/workspace') + }, + } + ) + } + const pendingUserIds = useMemo(() => { const ids = new Set() if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId) @@ -75,6 +89,8 @@ 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) return ids }, [ setUserRole.isPending, @@ -83,6 +99,8 @@ export function Admin() { banUser.variables, unbanUser.isPending, unbanUser.variables, + impersonateUser.isPending, + impersonateUser.variables, ]) return (
@@ -152,9 +170,10 @@ export function Admin() {

)} - {(setUserRole.error || banUser.error || unbanUser.error) && ( + {(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) && (

- {(setUserRole.error || banUser.error || unbanUser.error)?.message ?? + {(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) + ?.message ?? 'Action failed. Please try again.'}

)} @@ -175,7 +194,7 @@ export function Admin() { Email Role Status - Actions + Actions
{usersData.users.length === 0 && ( @@ -206,9 +225,21 @@ export function Admin() { Active )} - + {u.id !== session?.user?.id && ( <> + -
- - ) -} - export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 01a000fd26b..b81c0289a1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -37,6 +37,8 @@ export function Admin() { const [searchQuery, setSearchQuery] = useState('') const [banUserId, setBanUserId] = useState(null) const [banReason, setBanReason] = useState('') + const [impersonatingUserId, setImpersonatingUserId] = useState(null) + const [impersonationGuardError, setImpersonationGuardError] = useState(null) const { data: usersData, @@ -70,10 +72,21 @@ 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') }, @@ -91,6 +104,7 @@ export function Admin() { 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, @@ -101,6 +115,7 @@ export function Admin() { unbanUser.variables, impersonateUser.isPending, impersonateUser.variables, + impersonatingUserId, ]) return (
@@ -170,10 +185,15 @@ export function Admin() {

)} - {(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) && ( + {(setUserRole.error || + banUser.error || + unbanUser.error || + impersonateUser.error || + impersonationGuardError) && (

- {(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) - ?.message ?? + {impersonationGuardError || + (setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) + ?.message || 'Action failed. Please try again.'}

)} @@ -234,9 +254,10 @@ export function Admin() { onClick={() => handleImpersonate(u.id)} disabled={pendingUserIds.has(u.id)} > - {impersonateUser.isPending && - (impersonateUser.variables as { userId?: string } | undefined)?.userId === - u.id + {(impersonatingUserId === u.id || + (impersonateUser.isPending && + (impersonateUser.variables as { userId?: string } | undefined) + ?.userId === u.id)) ? 'Switching...' : 'Impersonate'} diff --git a/apps/sim/components/emcn/components/banner/banner.tsx b/apps/sim/components/emcn/components/banner/banner.tsx index a8c9fb9ebbb..8c374cf27c6 100644 --- a/apps/sim/components/emcn/components/banner/banner.tsx +++ b/apps/sim/components/emcn/components/banner/banner.tsx @@ -3,6 +3,7 @@ import type { HTMLAttributes, ReactNode } from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/core/utils/cn' +import { Button, type ButtonProps } from '@/components/emcn/components/button/button' const bannerVariants = cva('shrink-0 px-[24px] py-[10px]', { variants: { @@ -19,13 +20,51 @@ const bannerVariants = cva('shrink-0 px-[24px] py-[10px]', { export interface BannerProps extends HTMLAttributes, VariantProps { - children: ReactNode + actionClassName?: string + actionDisabled?: boolean + actionLabel?: ReactNode + actionProps?: Omit + actionVariant?: ButtonProps['variant'] + children?: ReactNode + contentClassName?: string + onAction?: () => void + text?: ReactNode + textClassName?: string } -export function Banner({ className, variant, children, ...props }: BannerProps) { +export function Banner({ + actionClassName, + actionDisabled, + actionLabel, + actionProps, + actionVariant = 'default', + children, + className, + contentClassName, + onAction, + text, + textClassName, + variant, + ...props +}: BannerProps) { return (
- {children} + {children ?? ( +
+

{text}

+ {actionLabel ? ( + + ) : null} +
+ )}
) } From 1776ee836db507b766098a969f2bfa794f591518 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 24 Mar 2026 11:09:52 -0700 Subject: [PATCH 3/5] Fix lint --- .../app/workspace/[workspaceId]/impersonation-banner.tsx | 6 ++++-- apps/sim/app/workspace/[workspaceId]/layout.tsx | 2 +- .../[workspaceId]/settings/components/admin/admin.tsx | 8 ++++---- apps/sim/components/emcn/components/banner/banner.tsx | 9 +++++++-- apps/sim/components/emcn/components/index.ts | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/impersonation-banner.tsx b/apps/sim/app/workspace/[workspaceId]/impersonation-banner.tsx index 69f85fa1606..a4a23572e25 100644 --- a/apps/sim/app/workspace/[workspaceId]/impersonation-banner.tsx +++ b/apps/sim/app/workspace/[workspaceId]/impersonation-banner.tsx @@ -2,8 +2,8 @@ import { useState } from 'react' import { Banner } from '@/components/emcn' -import { useStopImpersonating } from '@/hooks/queries/admin-users' 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.` @@ -25,7 +25,9 @@ export function ImpersonationBanner() { variant='destructive' text={getImpersonationBannerText(userLabel, userEmail)} textClassName='text-red-700 dark:text-red-300' - actionLabel={stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating'} + actionLabel={ + stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating' + } actionVariant='destructive' actionDisabled={stopImpersonating.isPending || isRedirecting} onAction={() => diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 3981d2d3c7e..fb018abb2f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -1,6 +1,6 @@ import { ToastProvider } from '@/components/emcn' -import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' 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' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index b81c0289a1c..9b8d24f2b82 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -254,10 +254,10 @@ export function Admin() { onClick={() => handleImpersonate(u.id)} disabled={pendingUserIds.has(u.id)} > - {(impersonatingUserId === u.id || - (impersonateUser.isPending && - (impersonateUser.variables as { userId?: string } | undefined) - ?.userId === u.id)) + {impersonatingUserId === u.id || + (impersonateUser.isPending && + (impersonateUser.variables as { userId?: string } | undefined) + ?.userId === u.id) ? 'Switching...' : 'Impersonate'} diff --git a/apps/sim/components/emcn/components/banner/banner.tsx b/apps/sim/components/emcn/components/banner/banner.tsx index 8c374cf27c6..98ebd0b7788 100644 --- a/apps/sim/components/emcn/components/banner/banner.tsx +++ b/apps/sim/components/emcn/components/banner/banner.tsx @@ -2,8 +2,8 @@ import type { HTMLAttributes, ReactNode } from 'react' import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from '@/lib/core/utils/cn' 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: { @@ -50,7 +50,12 @@ export function Banner({ return (
{children ?? ( -
+

{text}

{actionLabel ? (