Fix stale token race forcing re-login on verify pages (#22573)

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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22573?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:
Raphaël Bosi
2026-07-06 14:58:32 +02:00
committed by GitHub
parent 2327ae7122
commit 29920738dc
7 changed files with 129 additions and 38 deletions
@@ -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<Options> = {}) => {
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}`;
@@ -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 <></>;
};
@@ -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) =>
</JotaiProvider>,
);
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();
});
});
@@ -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();
});
});
@@ -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);
@@ -279,9 +279,11 @@ export const useAuth = () => {
handleSetAuthTokens(authTokens);
setIsAppEffectRedirectEnabled(false);
await loadCurrentUser();
setIsAppEffectRedirectEnabled(true);
try {
await loadCurrentUser();
} finally {
setIsAppEffectRedirectEnabled(true);
}
},
[loadCurrentUser, handleSetAuthTokens, setIsAppEffectRedirectEnabled],
);
@@ -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 {