From b74ed36ce31927c2fbd35edb366b6aabd99d3ee3 Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Mon, 29 Sep 2025 17:10:46 +0200
Subject: [PATCH] Add impersonate banner (#14734)
Related to https://github.com/twentyhq/twenty/pull/14360
@Bonapara, let you tell me if wording and banner color are ok. This
banner is displayed when a user is impersonating an other user
---
.../auth/states/isImpersonatingState.ts | 24 +++++++++++++++++
.../components/InformationBannerWrapper.tsx | 5 ++++
.../InformationBannerIsImpersonating.tsx | 27 +++++++++++++++++++
.../members/ManageMembersDropdownMenu.tsx | 6 ++++-
4 files changed, 61 insertions(+), 1 deletion(-)
create mode 100644 packages/twenty-front/src/modules/auth/states/isImpersonatingState.ts
create mode 100644 packages/twenty-front/src/modules/information-banner/components/impersonate/InformationBannerIsImpersonating.tsx
diff --git a/packages/twenty-front/src/modules/auth/states/isImpersonatingState.ts b/packages/twenty-front/src/modules/auth/states/isImpersonatingState.ts
new file mode 100644
index 0000000000..ae898bed59
--- /dev/null
+++ b/packages/twenty-front/src/modules/auth/states/isImpersonatingState.ts
@@ -0,0 +1,24 @@
+import { jwtDecode } from 'jwt-decode';
+import { selector } from 'recoil';
+import { isDefined } from 'twenty-shared/utils';
+import { tokenPairState } from './tokenPairState';
+
+export const isImpersonatingState = selector({
+ key: 'isImpersonatingState',
+ get: ({ get }) => {
+ const tokenPair = get(tokenPairState);
+
+ if (!isDefined(tokenPair?.accessOrWorkspaceAgnosticToken?.token)) {
+ return false;
+ }
+
+ try {
+ const decodedToken = jwtDecode<{ isImpersonating: boolean }>(
+ tokenPair.accessOrWorkspaceAgnosticToken.token,
+ );
+ return decodedToken?.isImpersonating ?? false;
+ } catch {
+ return false;
+ }
+ },
+});
diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
index 00cb4cb3d2..49b5eb7d7d 100644
--- a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
+++ b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
@@ -1,13 +1,16 @@
+import { isImpersonatingState } from '@/auth/states/isImpersonatingState';
import { InformationBannerBillingSubscriptionPaused } from '@/information-banner/components/billing/InformationBannerBillingSubscriptionPaused';
import { InformationBannerEndTrialPeriod } from '@/information-banner/components/billing/InformationBannerEndTrialPeriod';
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
+import { InformationBannerIsImpersonating } from '@/information-banner/components/impersonate/InformationBannerIsImpersonating';
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
import { useIsSomeMeteredProductCapReached } from '@/workspace/hooks/useIsSomeMeteredProductCapReached';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
+import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
@@ -27,6 +30,7 @@ export const InformationBannerWrapper = () => {
WorkspaceActivationStatus.SUSPENDED,
);
const isSomeMeteredProductCapReached = useIsSomeMeteredProductCapReached();
+ const isImpersonating = useRecoilValue(isImpersonatingState);
const displayBillingSubscriptionPausedBanner =
isWorkspaceSuspended && subscriptionStatus === SubscriptionStatus.Paused;
@@ -54,6 +58,7 @@ export const InformationBannerWrapper = () => {
)}
{displayFailPaymentInfoBanner && }
{displayEndTrialPeriodBanner && }
+ {isImpersonating && }
);
};
diff --git a/packages/twenty-front/src/modules/information-banner/components/impersonate/InformationBannerIsImpersonating.tsx b/packages/twenty-front/src/modules/information-banner/components/impersonate/InformationBannerIsImpersonating.tsx
new file mode 100644
index 0000000000..786728200b
--- /dev/null
+++ b/packages/twenty-front/src/modules/information-banner/components/impersonate/InformationBannerIsImpersonating.tsx
@@ -0,0 +1,27 @@
+import { useAuth } from '@/auth/hooks/useAuth';
+import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
+import { InformationBanner } from '@/information-banner/components/InformationBanner';
+import { t } from '@lingui/core/macro';
+import { useRecoilValue } from 'recoil';
+import { isDefined } from 'twenty-shared/utils';
+import { IconLogout } from 'twenty-ui/display';
+
+export const InformationBannerIsImpersonating = () => {
+ const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
+ const { signOut } = useAuth();
+
+ if (!isDefined(currentWorkspaceMember)) {
+ return null;
+ }
+
+ const impersonatedUser = `${currentWorkspaceMember.name.firstName} ${currentWorkspaceMember.name.lastName} (${currentWorkspaceMember.userEmail})`;
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/members/ManageMembersDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/members/ManageMembersDropdownMenu.tsx
index 9bcc7fcba4..cca7bdcadb 100644
--- a/packages/twenty-front/src/modules/settings/members/ManageMembersDropdownMenu.tsx
+++ b/packages/twenty-front/src/modules/settings/members/ManageMembersDropdownMenu.tsx
@@ -1,3 +1,4 @@
+import { isImpersonatingState } from '@/auth/states/isImpersonatingState';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -5,6 +6,7 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { t } from '@lingui/core/macro';
+import { useRecoilValue } from 'recoil';
import { IconDotsVertical, IconSpy, IconTrash } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
@@ -24,7 +26,9 @@ export const ManageMembersDropdownMenu = ({
onImpersonate,
}: ManageMembersDropdownMenuProps) => {
const { closeDropdown } = useCloseDropdown();
- const canImpersonate = useHasPermissionFlag(PermissionFlagType.IMPERSONATE);
+ const isImpersonating = useRecoilValue(isImpersonatingState);
+ const canImpersonate =
+ useHasPermissionFlag(PermissionFlagType.IMPERSONATE) && !isImpersonating;
return (