Fix infinite redirect loop when logging out of a suspended workspace (#22949)
## 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.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22949?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user