From 120793f69fc3ee446dd011f98c4f23964eea9509 Mon Sep 17 00:00:00 2001 From: Parship Chowdhury Date: Tue, 2 Jun 2026 18:45:44 +0530 Subject: [PATCH] fix: block self-impersonation in admin panel (#21130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Issue - From Settings -> Admin Panel -> Workspace -> Members, impersonating the currently logged-in user still issued an impersonation login token. Token exchange produced invalid impersonation JWTs (`impersonatorUserWorkspaceId === impersonatedUserWorkspaceId`). JWT validation then failed with `User cannot impersonate themselves`, leaving the app in an endless loading state until cookies were cleared. - Closes #21086 ## Approach I was first thinking of to only hide the impersonate button for the logged-in user in the admin, since they can not click what isn’t shown (as I thought it was just a frontend issue). But that was not enough: - The `impersonate` mutation can still be called directly (GraphQL client, scripts, devtools). - Before this fix, the mutation could succeed and only fail later at JWT validation, which led to invalid tokens and a broken session. So the PR does both: - Frontend: hide/disable self-impersonation in the UI and avoid reloading on failed token exchange (UX). - Backend: reject self-impersonation in `ImpersonationService` and at token exchange (enforcement, fail fast before bad tokens). Hiding the button is the right product behavior; the backend change is what makes the rule real and safe. ## How to test Manual: - Log in as a user with admin impersonation. - Go to Settings -> Admin panel -> Workspace -> open your workspace -> members. - Confirm your row has no Impersonate button; other members still do. - Open Admin Panel -> User for yourself -> confirm no impersonate button. - Open Settings -> Members -> your own member profile -> confirm no Impersonate action. - Impersonate another member -> should work as before Automated: `npx jest impersonation.service.spec` ### Before: Screenshot 2026-06-02 122224 ### After: Screenshot 2026-06-02 122333 --------- Signed-off-by: Parship Chowdhury Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> --- .../src/modules/auth/hooks/useAuth.ts | 3 ++ .../auth/hooks/useImpersonationSession.ts | 10 ++++- .../admin-panel/hooks/useHandleImpersonate.ts | 14 ++++++- .../admin-panel/SettingsAdminUserDetail.tsx | 3 +- .../SettingsAdminWorkspaceDetail.tsx | 30 ++++++++------- .../members/SettingsWorkspaceMember.tsx | 31 +++++++++++++--- .../engine/core-modules/auth/auth.resolver.ts | 9 +++++ .../__tests__/impersonation.service.spec.ts | 37 +++++++++++++++++++ .../services/impersonation.service.ts | 9 +++++ 9 files changed, 122 insertions(+), 24 deletions(-) diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index b23c2360fa..66e22df49c 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -272,6 +272,7 @@ export const useAuth = () => { handleSetLoginToken(loginToken); navigate(AppPath.SignInUp); setSignInUpStep(SignInUpStep.TwoFactorAuthenticationProvision); + return; } if ( @@ -283,7 +284,9 @@ export const useAuth = () => { handleSetLoginToken(loginToken); navigate(AppPath.SignInUp); setSignInUpStep(SignInUpStep.TwoFactorAuthenticationVerification); + return; } + throw error; } }, [ diff --git a/packages/twenty-front/src/modules/auth/hooks/useImpersonationSession.ts b/packages/twenty-front/src/modules/auth/hooks/useImpersonationSession.ts index 3a5c5a8b8d..17dfd9cff3 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useImpersonationSession.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useImpersonationSession.ts @@ -1,5 +1,5 @@ -import { useCallback } from 'react'; import { useStore } from 'jotai'; +import { useCallback } from 'react'; import { useAuth } from '@/auth/hooks/useAuth'; import { tokenPairState } from '@/auth/states/tokenPairState'; @@ -40,7 +40,13 @@ export const useImpersonationSession = () => { ); } - await getAuthTokensFromLoginToken(loginToken); + try { + await getAuthTokensFromLoginToken(loginToken); + } catch (error) { + sessionStorage.removeItem(IMPERSONATION_SESSION_KEY); + throw error; + } + reloadWithSession(targetPath); }, [store, getAuthTokensFromLoginToken], diff --git a/packages/twenty-front/src/modules/settings/admin-panel/hooks/useHandleImpersonate.ts b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useHandleImpersonate.ts index 92857e74c7..eee9fd7808 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/hooks/useHandleImpersonate.ts +++ b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useHandleImpersonate.ts @@ -1,10 +1,13 @@ import { useState } from 'react'; import { useMutation } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; import { AppPath } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useImpersonationSession } from '@/auth/hooks/useImpersonationSession'; +import { currentUserState } from '@/auth/states/currentUserState'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; @@ -12,6 +15,7 @@ import { ImpersonateDocument } from '~/generated-metadata/graphql'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; export const useHandleImpersonate = () => { + const currentUser = useAtomStateValue(currentUserState); const currentWorkspace = useAtomStateValue(currentWorkspaceState); const { enqueueErrorSnackBar } = useSnackBar(); const { startImpersonating } = useImpersonationSession(); @@ -22,6 +26,14 @@ export const useHandleImpersonate = () => { ); const handleImpersonate = async (userId: string, workspaceId: string) => { + if (!isDefined(currentUser?.id) || userId === currentUser.id) { + enqueueErrorSnackBar({ + message: t`You cannot impersonate your own account`, + }); + + return; + } + setImpersonatingUserId(userId); await impersonate({ diff --git a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminUserDetail.tsx b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminUserDetail.tsx index 0b4837560d..467339567f 100644 --- a/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminUserDetail.tsx +++ b/packages/twenty-front/src/pages/settings/admin-panel/SettingsAdminUserDetail.tsx @@ -166,7 +166,8 @@ export const SettingsAdminUserDetail = () => { /> {currentUser?.canImpersonate && activeWorkspace && - isDefined(user) && ( + isDefined(user) && + user.id !== currentUser.id && (