From 7ec7774087b6f2b0a80d150021e811ae24782736 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 16 Jul 2026 14:05:56 +0200 Subject: [PATCH] Fix infinite redirect loop when logging out of a suspended workspace (#22949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Clicking **Log out** on a workspace suspended for a past-due subscription locks the tab into an infinite redirect loop that hammers the server with unauthenticated GraphQL requests until the tab is closed. Reproduced on cloud: workspace with `Pro plan • Past due` (activation status `SUSPENDED`), user forced onto `/settings/billing`, click Log out → tab freezes, URL flip-flops between `/welcome` and `/settings/billing`, requests stream out continuously. ## Root cause `clearSession` nulls the `tokenPairState` atom and removes the persisted session localStorage keys, but leaves the **in-memory** `currentWorkspaceState`/`currentUserState` atoms populated, relying on the subsequent `window.location.assign('/welcome')` reload to reset them. `PageChangeEffect` keeps running until that reload commits, and the intermediate state (no token + suspended workspace) makes `usePageChangeEffectNavigateLocation` ping-pong: - on `/settings/billing`: no token → navigate to `/welcome` - on `/welcome`: the no-token guard is skipped (`SignInUp` is in `ONGOING_USER_CREATION_PATHS`), then `isWorkspaceSuspended` reads the **stale** workspace atom → navigate back to `/settings/billing` The synchronous navigation loop pegs the main thread, so the pending full-page navigation never commits and the loop never resets. Every bounce remounts pages whose queries refire without a token (each one erroring `UNAUTHENTICATED`), plus Sentry envelopes — the server spam. Verified during repro: mid-loop the tab had `tokenPairState="null"` and no `currentWorkspaceState` in localStorage (the loop runs fully unauthenticated off the in-memory atom), and an injected `localStorage.setItem` wrapper survived the whole loop, proving the page never reloaded. ## Fix Clear the same in-memory auth atoms in `clearSession` that `onUnauthenticatedError` (useApolloFactory) already clears: `currentUserState`, `currentWorkspaceState`, `currentWorkspaceMemberState`, `currentUserWorkspaceState`. With the workspace atom gone, the suspended guard can't fire after logout, the ping-pong never starts, and the redirect to `/welcome` commits normally. ## Test Extended the `useAuth` sign-out test: seeds a suspended `currentWorkspaceState` and a `currentUserState` before `signOut()` and asserts both are null afterwards. Review in cubic --- .../auth/hooks/__tests__/useAuth.test.tsx | 18 ++++++++++++++++++ .../src/modules/auth/hooks/useAuth.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx index d7862eb45c..dea9cdc1d0 100644 --- a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx +++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx @@ -11,10 +11,19 @@ import { results, token, } from '@/auth/hooks/__mocks__/useAuth'; +import { + type CurrentUser, + currentUserState, +} from '@/auth/states/currentUserState'; +import { + type CurrentWorkspace, + currentWorkspaceState, +} from '@/auth/states/currentWorkspaceState'; import { returnToPathState } from '@/auth/states/returnToPathState'; import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext'; import { renderHook } from '@testing-library/react'; import { getDefaultStore } from 'jotai'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; const redirectSpy = jest.fn(); @@ -184,6 +193,13 @@ describe('useAuth', () => { it('should handle sign-out', async () => { sessionStorage.setItem('lingering-key', 'should-be-cleared'); + getDefaultStore().set(currentWorkspaceState.atom, { + id: 'workspace-id', + activationStatus: WorkspaceActivationStatus.SUSPENDED, + } as CurrentWorkspace); + getDefaultStore().set(currentUserState.atom, { + id: 'user-id', + } as CurrentUser); const { result } = renderHooks(); @@ -192,6 +208,8 @@ describe('useAuth', () => { }); expect(sessionStorage.length).toBe(0); + expect(getDefaultStore().get(currentWorkspaceState.atom)).toBeNull(); + expect(getDefaultStore().get(currentUserState.atom)).toBeNull(); }); it('should handle credential sign-up', async () => { diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 13f628a9a6..6cb4077883 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -22,6 +22,10 @@ import { VerifyEmailAndGetWorkspaceAgnosticTokenDocument, } from '~/generated-metadata/graphql'; +import { currentUserState } from '@/auth/states/currentUserState'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { returnToPathState } from '@/auth/states/returnToPathState'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { clearSessionLocalStorageKeys } from '@/auth/utils/clearSessionLocalStorageKeys'; @@ -111,8 +115,12 @@ export const useAuth = () => { const clearSession = useCallback(() => { sessionStorage.clear(); - clearSessionLocalStorageKeys(); store.set(tokenPairState.atom, null); + store.set(currentUserState.atom, null); + store.set(currentWorkspaceState.atom, null); + store.set(currentWorkspaceMemberState.atom, null); + store.set(currentUserWorkspaceState.atom, null); + clearSessionLocalStorageKeys(); setLastAuthenticateWorkspaceDomain(null); window.location.assign(AppPath.SignInUp); }, [store, setLastAuthenticateWorkspaceDomain]);