From 29920738dcf47da9fab708dd3f5f0ef232c11d71 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Mon, 6 Jul 2026 14:58:32 +0200
Subject: [PATCH] Fix stale token race forcing re-login on verify pages
(#22573)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Landing on /verify often forced users to refresh and log in again. The
culprit is a logout side effect triggered by a stale token: when a
previous session's token pair is still in localStorage, boot queries use
it, fail, and the failed token renewal reacts by logging the user out
(onUnauthenticatedError clears the token pair). That logout fires while
the loginToken exchange is running, so it can wipe the fresh session
that was just stored.
Fix: clear the stale token pair right before exchanging the loginToken
(in useVerifyLogin, so both /verify and /verify-email are covered) —
with no stale token to renew, the logout side effect never fires against
the new session. Also removes the redundant clientConfig gate on the
verify effect, stops that same logout side effect from redirecting users
off /verify-email mid-verification, and always re-enables app redirects
after loading the user.
Note: opening a loginToken link now replaces an existing valid session
instead of keeping it.
---
.../modules/apollo/hooks/useApolloFactory.ts | 8 +--
.../components/VerifyLoginTokenEffect.tsx | 10 +---
.../components/__tests__/VerifyEmail.test.tsx | 45 ++++++++++++++++
.../__tests__/VerifyLoginTokenEffect.test.tsx | 53 +++++++++++--------
.../hooks/__tests__/useVerifyLogin.test.ts | 39 +++++++++++++-
.../src/modules/auth/hooks/useAuth.ts | 8 +--
.../src/modules/auth/hooks/useVerifyLogin.ts | 4 ++
7 files changed, 129 insertions(+), 38 deletions(-)
diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts
index 088f1362c7..d0e10f69a5 100644
--- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts
+++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts
@@ -3,6 +3,7 @@ import { useMemo, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ApolloFactory, type Options } from '@/apollo/services/apollo.factory';
+import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
@@ -75,10 +76,9 @@ export const useApolloFactory = (options: Partial = {}) => {
setCurrentWorkspace(null);
setCurrentUserWorkspace(null);
if (
- !isMatchingLocation(locationRef.current, AppPath.Verify) &&
- !isMatchingLocation(locationRef.current, AppPath.SignInUp) &&
- !isMatchingLocation(locationRef.current, AppPath.Invite) &&
- !isMatchingLocation(locationRef.current, AppPath.ResetPassword)
+ ![...ONGOING_USER_CREATION_PATHS, AppPath.ResetPassword].some(
+ (path) => isMatchingLocation(locationRef.current, path),
+ )
) {
const path = `${locationRef.current.pathname}${locationRef.current.search}${locationRef.current.hash}`;
diff --git a/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx
index 6bac7ad32f..fe88ad808c 100644
--- a/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx
+++ b/packages/twenty-front/src/modules/auth/components/VerifyLoginTokenEffect.tsx
@@ -3,8 +3,6 @@ import { useSearchParams } from 'react-router-dom';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
-import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
-import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
@@ -17,15 +15,11 @@ export const VerifyLoginTokenEffect = () => {
const navigate = useNavigateApp();
const { verifyLoginToken } = useVerifyLogin();
- const { isSaved: clientConfigLoaded } = useAtomStateValue(
- clientConfigApiStatusState,
- );
-
// oxlint-disable-next-line twenty/no-state-useref
const hasVerifiedRef = useRef(false);
useEffect(() => {
- if (!clientConfigLoaded || hasVerifiedRef.current) {
+ if (hasVerifiedRef.current) {
return;
}
@@ -37,7 +31,7 @@ export const VerifyLoginTokenEffect = () => {
navigate(AppPath.SignInUp);
}
// oxlint-disable-next-line react-hooks/exhaustive-deps
- }, [clientConfigLoaded]);
+ }, []);
return <>>;
};
diff --git a/packages/twenty-front/src/modules/auth/components/__tests__/VerifyEmail.test.tsx b/packages/twenty-front/src/modules/auth/components/__tests__/VerifyEmail.test.tsx
index e4c2b2ed0a..ab73b77eca 100644
--- a/packages/twenty-front/src/modules/auth/components/__tests__/VerifyEmail.test.tsx
+++ b/packages/twenty-front/src/modules/auth/components/__tests__/VerifyEmail.test.tsx
@@ -8,6 +8,7 @@ import { AppPath } from 'twenty-shared/types';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { VerifyEmail } from '@/auth/components/VerifyEmail';
+import { tokenPairState } from '@/auth/states/tokenPairState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import {
jotaiStore,
@@ -89,9 +90,21 @@ const renderVerifyEmail = (initialEntry: string) =>
,
);
+const staleTokenPair = {
+ accessOrWorkspaceAgnosticToken: {
+ token: 'stale-access-token',
+ expiresAt: '2020-01-01T00:00:00.000Z',
+ },
+ refreshToken: {
+ token: 'stale-refresh-token',
+ expiresAt: '2020-01-01T00:00:00.000Z',
+ },
+};
+
describe('VerifyEmail', () => {
beforeEach(() => {
jest.clearAllMocks();
+ localStorage.clear();
resetJotaiStore();
isOnAWorkspaceValue = false;
// The verification effect is gated on the client config having loaded.
@@ -155,4 +168,36 @@ describe('VerifyEmail', () => {
expect(verifyEmailAndGetWorkspaceAgnosticTokenMock).not.toHaveBeenCalled();
expect(navigateMock).not.toHaveBeenCalledWith(AppPath.SignInUp);
});
+
+ it('exchanges the login token without redirecting when already on the workspace origin', async () => {
+ isOnAWorkspaceValue = true;
+ verifyEmailAndGetLoginTokenMock.mockResolvedValue({
+ loginToken: { token: 'login-token' },
+ workspaceUrls: { subdomainUrl: `${window.location.origin}/` },
+ });
+
+ renderVerifyEmail(VERIFY_EMAIL_URL);
+
+ await waitFor(() => {
+ expect(verifyLoginTokenMock).toHaveBeenCalledWith('login-token');
+ });
+ expect(redirectToWorkspaceDomainMock).not.toHaveBeenCalled();
+ });
+
+ it('keeps the token pair when redirecting to another workspace domain', async () => {
+ isOnAWorkspaceValue = true;
+ jotaiStore.set(tokenPairState.atom, staleTokenPair);
+ verifyEmailAndGetLoginTokenMock.mockResolvedValue({
+ loginToken: { token: 'login-token' },
+ workspaceUrls: { subdomainUrl: 'https://foo.twenty.com/' },
+ });
+
+ renderVerifyEmail(VERIFY_EMAIL_URL);
+
+ await waitFor(() => {
+ expect(redirectToWorkspaceDomainMock).toHaveBeenCalled();
+ });
+ expect(jotaiStore.get(tokenPairState.atom)).toEqual(staleTokenPair);
+ expect(verifyLoginTokenMock).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/twenty-front/src/modules/auth/components/__tests__/VerifyLoginTokenEffect.test.tsx b/packages/twenty-front/src/modules/auth/components/__tests__/VerifyLoginTokenEffect.test.tsx
index 723c78f69a..95ea0d34d3 100644
--- a/packages/twenty-front/src/modules/auth/components/__tests__/VerifyLoginTokenEffect.test.tsx
+++ b/packages/twenty-front/src/modules/auth/components/__tests__/VerifyLoginTokenEffect.test.tsx
@@ -1,10 +1,11 @@
-import { act, render, waitFor } from '@testing-library/react';
+import { render, waitFor } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { StrictMode } from 'react';
import { MemoryRouter } from 'react-router-dom';
+import { AppPath } from 'twenty-shared/types';
import { VerifyLoginTokenEffect } from '@/auth/components/VerifyLoginTokenEffect';
-import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
+import { tokenPairState } from '@/auth/states/tokenPairState';
import {
jotaiStore,
resetJotaiStore,
@@ -21,17 +22,15 @@ jest.mock('~/hooks/useNavigateApp', () => ({
useNavigateApp: () => navigateMock,
}));
-jest.mock('@/auth/hooks/useHasAccessTokenPair', () => ({
- useHasAccessTokenPair: () => false,
-}));
-
-const setClientConfigSaved = (isSaved: boolean) => {
- jotaiStore.set(clientConfigApiStatusState.atom, {
- isLoadedOnce: true,
- isLoading: false,
- isErrored: false,
- isSaved,
- });
+const staleTokenPair = {
+ accessOrWorkspaceAgnosticToken: {
+ token: 'stale-access-token',
+ expiresAt: '2020-01-01T00:00:00.000Z',
+ },
+ refreshToken: {
+ token: 'stale-refresh-token',
+ expiresAt: '2020-01-01T00:00:00.000Z',
+ },
};
const renderEffect = (initialEntry: string) =>
@@ -48,25 +47,35 @@ const renderEffect = (initialEntry: string) =>
describe('VerifyLoginTokenEffect', () => {
beforeEach(() => {
jest.clearAllMocks();
+ localStorage.clear();
resetJotaiStore();
- setClientConfigSaved(true);
});
- it('verifies the login token at most once even when the gating config re-triggers the effect', async () => {
+ it('verifies the login token at most once under StrictMode', async () => {
renderEffect('/verify?loginToken=login-token');
await waitFor(() => {
expect(verifyLoginTokenMock).toHaveBeenCalledWith('login-token');
});
expect(verifyLoginTokenMock).toHaveBeenCalledTimes(1);
+ });
- await act(async () => {
- setClientConfigSaved(false);
- });
- await act(async () => {
- setClientConfigSaved(true);
- });
+ it('navigates to sign in up when neither login token nor token pair is present', async () => {
+ renderEffect('/verify');
- expect(verifyLoginTokenMock).toHaveBeenCalledTimes(1);
+ await waitFor(() => {
+ expect(navigateMock).toHaveBeenCalledWith(AppPath.SignInUp);
+ });
+ expect(verifyLoginTokenMock).not.toHaveBeenCalled();
+ });
+
+ it('keeps the existing token pair when arriving without a login token', () => {
+ jotaiStore.set(tokenPairState.atom, staleTokenPair);
+
+ renderEffect('/verify');
+
+ expect(jotaiStore.get(tokenPairState.atom)).toEqual(staleTokenPair);
+ expect(navigateMock).not.toHaveBeenCalled();
+ expect(verifyLoginTokenMock).not.toHaveBeenCalled();
});
});
diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useVerifyLogin.test.ts b/packages/twenty-front/src/modules/auth/hooks/__tests__/useVerifyLogin.test.ts
index 396179fef6..e9204aef00 100644
--- a/packages/twenty-front/src/modules/auth/hooks/__tests__/useVerifyLogin.test.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useVerifyLogin.test.ts
@@ -1,12 +1,18 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { renderHook } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { AppPath } from 'twenty-shared/types';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { useAuth } from '@/auth/hooks/useAuth';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
+import { tokenPairState } from '@/auth/states/tokenPairState';
+import {
+ jotaiStore,
+ resetJotaiStore,
+} from '@/ui/utilities/state/jotai/jotaiStore';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
@@ -27,11 +33,26 @@ dynamicActivate(SOURCE_LOCALE);
const renderHooks = () => {
const { result } = renderHook(() => useVerifyLogin(), {
- wrapper: ({ children }) => I18nProvider({ i18n, children }),
+ wrapper: ({ children }) =>
+ JotaiProvider({
+ store: jotaiStore,
+ children: I18nProvider({ i18n, children }),
+ }),
});
return { result };
};
+const staleTokenPair = {
+ accessOrWorkspaceAgnosticToken: {
+ token: 'stale-access-token',
+ expiresAt: '2020-01-01T00:00:00.000Z',
+ },
+ refreshToken: {
+ token: 'stale-refresh-token',
+ expiresAt: '2020-01-01T00:00:00.000Z',
+ },
+};
+
describe('useVerifyLogin', () => {
const mockGetAuthTokensFromLoginToken = jest.fn();
const mockEnqueueErrorSnackBar = jest.fn();
@@ -39,6 +60,8 @@ describe('useVerifyLogin', () => {
beforeEach(() => {
jest.clearAllMocks();
+ localStorage.clear();
+ resetJotaiStore();
(useAuth as jest.Mock).mockReturnValue({
getAuthTokensFromLoginToken: mockGetAuthTokensFromLoginToken,
@@ -59,6 +82,20 @@ describe('useVerifyLogin', () => {
expect(mockGetAuthTokensFromLoginToken).toHaveBeenCalledWith('test-token');
});
+ it('should clear the existing token pair before exchanging the login token', async () => {
+ jotaiStore.set(tokenPairState.atom, staleTokenPair);
+ const tokenPairsAtExchangeTime: unknown[] = [];
+ mockGetAuthTokensFromLoginToken.mockImplementation(() => {
+ tokenPairsAtExchangeTime.push(jotaiStore.get(tokenPairState.atom));
+ });
+
+ const { result } = renderHooks();
+
+ await result.current.verifyLoginToken('test-token');
+
+ expect(tokenPairsAtExchangeTime).toEqual([null]);
+ });
+
it('should handle verification error', async () => {
const error = new Error('Verification failed');
mockGetAuthTokensFromLoginToken.mockRejectedValueOnce(error);
diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
index 6bcd27bacb..13f628a9a6 100644
--- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
@@ -279,9 +279,11 @@ export const useAuth = () => {
handleSetAuthTokens(authTokens);
setIsAppEffectRedirectEnabled(false);
- await loadCurrentUser();
-
- setIsAppEffectRedirectEnabled(true);
+ try {
+ await loadCurrentUser();
+ } finally {
+ setIsAppEffectRedirectEnabled(true);
+ }
},
[loadCurrentUser, handleSetAuthTokens, setIsAppEffectRedirectEnabled],
);
diff --git a/packages/twenty-front/src/modules/auth/hooks/useVerifyLogin.ts b/packages/twenty-front/src/modules/auth/hooks/useVerifyLogin.ts
index 7aabc0758d..49429b1031 100644
--- a/packages/twenty-front/src/modules/auth/hooks/useVerifyLogin.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/useVerifyLogin.ts
@@ -1,5 +1,7 @@
import { useAuth } from '@/auth/hooks/useAuth';
+import { tokenPairState } from '@/auth/states/tokenPairState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useLingui } from '@lingui/react/macro';
import { AppPath } from 'twenty-shared/types';
import { useNavigateApp } from '~/hooks/useNavigateApp';
@@ -7,10 +9,12 @@ import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useVerifyLogin = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const navigate = useNavigateApp();
+ const setTokenPair = useSetAtomState(tokenPairState);
const { getAuthTokensFromLoginToken } = useAuth();
const { t } = useLingui();
const verifyLoginToken = async (loginToken: string) => {
+ setTokenPair(null);
try {
await getAuthTokensFromLoginToken(loginToken);
} catch {