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 {