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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+31
-22
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user