diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index 7678f8b100..b2fe1fb43f 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]/impersonation-banner.tsx b/apps/sim/app/workspace/[workspaceId]/impersonation-banner.tsx new file mode 100644 index 0000000000..a4a23572e2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/impersonation-banner.tsx @@ -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 ( + + stopImpersonating.mutate(undefined, { + onError: () => { + setIsRedirecting(false) + }, + onSuccess: () => { + setIsRedirecting(true) + window.location.assign('/workspace') + }, + }) + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index dba9198ba5..ad7b57e437 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -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 -
+
+ -
- -
-
-
- {children} +
+
+ +
+
+
+ {children} +
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 5161bb3c5d..9b8d24f2b8 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) @@ -35,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, @@ -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() 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 (
@@ -152,9 +185,15 @@ export function Admin() {

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

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

)} @@ -175,7 +214,7 @@ export function Admin() { Email Role Status - Actions + Actions
{usersData.users.length === 0 && ( @@ -206,9 +245,22 @@ export function Admin() { Active )} - + {u.id !== session?.user?.id && ( <> + + ) : null} +
+ )} +
+ ) +} diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index 2a93e4ade9..269510bda0 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -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 { diff --git a/apps/sim/hooks/queries/admin-users.ts b/apps/sim/hooks/queries/admin-users.ts index 6f9f6bba73..2ede1af8ab 100644 --- a/apps/sim/hooks/queries/admin-users.ts +++ b/apps/sim/hooks/queries/admin-users.ts @@ -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) + }, + }) +}