From 6351c6c1c666d00a1702eacb28b271bca15e529a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 2 Mar 2026 19:00:48 +0100 Subject: [PATCH] feat: remember original URL and redirect after login (#18308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Implement a return-to-path mechanism that preserves the user's intended destination across authentication flows (login, magic link, cross-domain redirects) - Uses layered persistence: Jotai atom (in-memory), sessionStorage with TTL (tab-switch resilience), URL query parameter (cross-domain propagation) - Includes path validation to prevent open redirects, automatic cleanup after successful login, and comprehensive test coverage - Replaces the unused `previousUrlState` with a robust `returnToPathState` system ## Test plan - [ ] Visit a deep link (e.g. `/objects/tasks`) while logged out — should redirect to login, then back to `/objects/tasks` after logging in - [ ] Visit an OAuth authorize link while logged out — should redirect to login, then to the authorize page - [ ] Test magic link flow: click sign-in link that opens new tab — should still redirect to original destination - [ ] Test cross-domain: visit `app.twenty.com/objects/tasks` — should preserve path through workspace domain redirect - [ ] Verify auth/onboarding paths are excluded from being saved as return paths - [ ] Verify return-to-path is cleared after successful navigation - [ ] All 215 existing `usePageChangeEffectNavigateLocation` tests pass Made with [Cursor](https://cursor.com) --- .../authentication/return-to-path.spec.ts | 115 ++++++++++++++++++ ...sePageChangeEffectNavigateLocation.test.ts | 38 +++++- .../usePageChangeEffectNavigateLocation.ts | 46 +++---- .../modules/apollo/hooks/useApolloFactory.ts | 13 +- .../effect-components/PageChangeEffect.tsx | 54 ++++++-- .../app/hooks/useInitializeQueryParamState.ts | 9 +- .../modules/auth/constants/OnboardingPaths.ts | 12 ++ .../constants/OngoingUserCreationPaths.ts | 8 ++ .../src/modules/auth/hooks/useAuth.ts | 3 + .../src/modules/auth/hooks/useReturnToPath.ts | 46 +++++++ .../components/SignInUpGlobalScopeForm.tsx | 8 +- ...eviousUrlState.ts => returnToPathState.ts} | 5 +- .../modules/auth/utils/isValidReturnToPath.ts | 24 ++++ ...useBuildSearchParamsFromUrlSyncedStates.ts | 4 + .../components/WorkspaceProviderEffect.tsx | 11 +- 15 files changed, 351 insertions(+), 45 deletions(-) create mode 100644 packages/twenty-e2e-testing/tests/authentication/return-to-path.spec.ts create mode 100644 packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts create mode 100644 packages/twenty-front/src/modules/auth/constants/OngoingUserCreationPaths.ts create mode 100644 packages/twenty-front/src/modules/auth/hooks/useReturnToPath.ts rename packages/twenty-front/src/modules/auth/states/{previousUrlState.ts => returnToPathState.ts} (55%) create mode 100644 packages/twenty-front/src/modules/auth/utils/isValidReturnToPath.ts diff --git a/packages/twenty-e2e-testing/tests/authentication/return-to-path.spec.ts b/packages/twenty-e2e-testing/tests/authentication/return-to-path.spec.ts new file mode 100644 index 0000000000..b3b1e54f8f --- /dev/null +++ b/packages/twenty-e2e-testing/tests/authentication/return-to-path.spec.ts @@ -0,0 +1,115 @@ +import { expect, test as base } from '@playwright/test'; +import { LoginPage } from '../../lib/pom/loginPage'; + +const test = base.extend<{ loginPage: LoginPage }>({ + loginPage: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await use(loginPage); + }, +}); + +const loginAndSelectWorkspace = async (loginPage: LoginPage, page: any) => { + await page.waitForLoadState('networkidle'); + await loginPage.clickLoginWithEmailIfVisible(); + await loginPage.typeEmail(process.env.DEFAULT_LOGIN!); + await loginPage.clickContinueButton(); + await loginPage.typePassword(process.env.DEFAULT_PASSWORD!); + await page.waitForLoadState('networkidle'); + await loginPage.clickSignInButton(); + await page.waitForLoadState('networkidle'); + + const workspaceButton = page.getByText('Apple', { exact: true }); + + await workspaceButton.waitFor({ state: 'visible', timeout: 15000 }).catch( + () => { + // Single workspace mode — no workspace selection + }, + ); + + if (await workspaceButton.isVisible()) { + await workspaceButton.click(); + } + + await page.waitForFunction( + () => + !window.location.href.includes('verify') && + !window.location.href.includes('welcome'), + { timeout: 15000 }, + ); +}; + +test.describe('Return-to-path after login', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('should redirect to deep link after login', async ({ + page, + loginPage, + }) => { + const deepLink = '/settings/accounts'; + + await test.step('Navigate to deep link while logged out', async () => { + await page.goto(deepLink); + await page.waitForURL('**/welcome'); + await page.waitForLoadState('domcontentloaded'); + }); + + await test.step('Log in and select workspace', async () => { + await loginAndSelectWorkspace(loginPage, page); + }); + + await test.step( + 'Verify redirected to original deep link', + async () => { + await page.waitForURL(`**${deepLink}`, { + timeout: 30000, + waitUntil: 'commit', + }); + expect(new URL(page.url()).pathname).toBe(deepLink); + }, + ); + + await test.step( + 'Verify return-to-path query param was consumed', + async () => { + const url = new URL(page.url()); + + expect(url.searchParams.has('returnToPath')).toBe(false); + }, + ); + }); + + test('should preserve path with query params across login', async ({ + page, + loginPage, + }) => { + const targetPath = + '/authorize?clientId=test-client-id&redirectUrl=https%3A%2F%2Fexample.com%2Fcallback'; + + await test.step( + 'Navigate to path with query params while logged out', + async () => { + await page.goto(targetPath); + await page.waitForURL('**/welcome'); + await page.waitForLoadState('domcontentloaded'); + }, + ); + + await test.step('Log in and select workspace', async () => { + await loginAndSelectWorkspace(loginPage, page); + }); + + await test.step( + 'Verify redirected to original path with query params', + async () => { + await page.waitForURL('**/authorize**', { timeout: 15000 }); + const url = new URL(page.url()); + + expect(url.pathname).toBe('/authorize'); + expect(url.searchParams.get('clientId')).toBe('test-client-id'); + expect(url.searchParams.get('redirectUrl')).toBe( + 'https://example.com/callback', + ); + }, + ); + }); +}); diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index a404aebe33..3e465eb64e 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -52,9 +52,11 @@ jest.mocked(useDefaultHomePagePath).mockReturnValue({ }); jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'); -jest.mocked(useIsCurrentLocationOnAWorkspace).mockReturnValue({ - isOnAWorkspace: true, -}); +const setupMockIsOnAWorkspace = (isOnAWorkspace: boolean) => { + jest.mocked(useIsCurrentLocationOnAWorkspace).mockReturnValue({ + isOnAWorkspace, + }); +}; jest.mock('react-router-dom'); const setupMockUseParams = (objectNamePlural?: string) => { @@ -68,12 +70,14 @@ const setupMockState = ( objectNamePlural?: string, verifyEmailRedirectPath?: string, calendarBookingPageId?: string | null, + returnToPath?: string, ) => { jest .mocked(useAtomStateValue) .mockReturnValueOnce(calendarBookingPageId ?? 'mock-calendar-id') .mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }]) - .mockReturnValueOnce(verifyEmailRedirectPath); + .mockReturnValueOnce(verifyEmailRedirectPath) + .mockReturnValueOnce(returnToPath ?? ''); }; // prettier-ignore @@ -83,9 +87,11 @@ const testCases: { isWorkspaceSuspended: boolean; onboardingStatus: OnboardingStatus | undefined; res: string | undefined; + isOnAWorkspace?: boolean; objectNamePluralFromParams?: string; objectNamePluralFromMetadata?: string; verifyEmailRedirectPath?: string; + returnToPath?: string; }[] = [ { loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, { loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, @@ -320,6 +326,15 @@ const testCases: { { loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, { loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision }, { loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + + // returnToPath: should redirect to saved path instead of defaultHomePagePath + { loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/authorize?clientId=abc', res: '/authorize?clientId=abc' }, + { loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/objects/tasks', res: '/objects/tasks' }, + { loc: AppPath.Index, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/settings/api-keys', res: '/settings/api-keys' }, + + // isOnAWorkspace:false — on default domain, don't redirect to returnToPath or defaultHomePagePath from auth pages + { loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined }, + { loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined }, ]; describe('usePageChangeEffectNavigateLocation', () => { @@ -330,17 +345,25 @@ describe('usePageChangeEffectNavigateLocation', () => { onboardingStatus, isWorkspaceSuspended, isLoggedIn, + isOnAWorkspace, objectNamePluralFromParams, objectNamePluralFromMetadata, verifyEmailRedirectPath, + returnToPath, res, }) => { setupMockIsMatchingLocation(loc); setupMockOnboardingStatus(onboardingStatus); setupMockIsWorkspaceActivationStatusEqualsTo(isWorkspaceSuspended); setupMockIsLogged(isLoggedIn); + setupMockIsOnAWorkspace(isOnAWorkspace ?? true); setupMockUseParams(objectNamePluralFromParams); - setupMockState(objectNamePluralFromMetadata, verifyEmailRedirectPath); + setupMockState( + objectNamePluralFromMetadata, + verifyEmailRedirectPath, + undefined, + returnToPath, + ); expect(usePageChangeEffectNavigateLocation()).toEqual(res); }, @@ -355,7 +378,10 @@ describe('usePageChangeEffectNavigateLocation', () => { .length) + ['nonExistingObjectInParam', 'existingObjectInParam:false'].length + ['caseWithRedirectionToVerifyEmailRedirectPath', 'caseWithout'] - .length, + .length + + ['returnToPath:verify', 'returnToPath:signInUp', 'returnToPath:index'] + .length + + ['notOnWorkspace:verify', 'notOnWorkspace:signInUp'].length, ); }); }); diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index e88684fce7..660bf2f95e 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -1,5 +1,8 @@ import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPathState'; +import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths'; +import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths'; import { useIsLogged } from '@/auth/hooks/useIsLogged'; +import { returnToPathState } from '@/auth/states/returnToPathState'; import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; @@ -7,6 +10,8 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo'; +import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath'; +import { isNonEmptyString } from '@sniptt/guards'; import { useLocation, useParams } from 'react-router-dom'; import { AppPath, SettingsPath } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -14,6 +19,12 @@ import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { OnboardingStatus } from '~/generated-metadata/graphql'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; +const readReturnToPathFromUrlSearchParams = (): string | null => { + const value = new URLSearchParams(window.location.search).get('returnToPath'); + + return value && isValidReturnToPath(value) ? value : null; +}; + export const usePageChangeEffectNavigateLocation = () => { const isLoggedIn = useIsLogged(); const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace(); @@ -27,22 +38,6 @@ export const usePageChangeEffectNavigateLocation = () => { const someMatchingLocationOf = (appPaths: AppPath[]): boolean => appPaths.some((appPath) => isMatchingLocation(location, appPath)); - const onGoingUserCreationPaths = [ - AppPath.Invite, - AppPath.SignInUp, - AppPath.VerifyEmail, - AppPath.Verify, - ]; - const onboardingPaths = [ - AppPath.CreateWorkspace, - AppPath.CreateProfile, - AppPath.SyncEmails, - AppPath.InviteTeam, - AppPath.PlanRequired, - AppPath.PlanRequiredSuccess, - AppPath.BookCallDecision, - AppPath.BookCall, - ]; const objectNamePlural = useParams().objectNamePlural ?? ''; const objectMetadataItems = useAtomStateValue(objectMetadataItemsState); @@ -53,10 +48,15 @@ export const usePageChangeEffectNavigateLocation = () => { verifyEmailRedirectPathState, ); + const returnToPath = useAtomStateValue(returnToPathState); + const resolvedReturnToPath = isNonEmptyString(returnToPath) + ? returnToPath + : readReturnToPathFromUrlSearchParams(); + if ( (!isLoggedIn || (isLoggedIn && !isOnAWorkspace)) && !someMatchingLocationOf([ - ...onGoingUserCreationPaths, + ...ONGOING_USER_CREATION_PATHS, AppPath.ResetPassword, ]) ) { @@ -135,15 +135,19 @@ export const usePageChangeEffectNavigateLocation = () => { if ( onboardingStatus === OnboardingStatus.COMPLETED && - someMatchingLocationOf([...onboardingPaths, ...onGoingUserCreationPaths]) && + someMatchingLocationOf([ + ...ONBOARDING_PATHS, + ...ONGOING_USER_CREATION_PATHS, + ]) && !isMatchingLocation(location, AppPath.ResetPassword) && - isLoggedIn + isLoggedIn && + isOnAWorkspace ) { - return defaultHomePagePath; + return resolvedReturnToPath ?? defaultHomePagePath; } if (isMatchingLocation(location, AppPath.Index) && isLoggedIn) { - return defaultHomePagePath; + return resolvedReturnToPath ?? defaultHomePagePath; } if ( diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index 614a0e0ed7..6c48286d4e 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -7,7 +7,8 @@ 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 { previousUrlState } from '@/auth/states/previousUrlState'; +import { returnToPathState } from '@/auth/states/returnToPathState'; +import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { appVersionState } from '@/client-config/states/appVersionState'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -36,7 +37,7 @@ export const useApolloFactory = (options: Partial> = {}) => { const setCurrentUser = useSetAtomState(currentUserState); const setCurrentUserWorkspace = useSetAtomState(currentUserWorkspaceState); - const setPreviousUrl = useSetAtomState(previousUrlState); + const setReturnToPath = useSetAtomState(returnToPathState); const location = useLocation(); const { enqueueErrorSnackBar } = useSnackBar(); @@ -76,7 +77,11 @@ export const useApolloFactory = (options: Partial> = {}) => { !isMatchingLocation(location, AppPath.Invite) && !isMatchingLocation(location, AppPath.ResetPassword) ) { - setPreviousUrl(`${location.pathname}${location.search}`); + const path = `${location.pathname}${location.search}${location.hash}`; + + if (isValidReturnToPath(path)) { + setReturnToPath(path); + } navigate(AppPath.SignInUp); } }, @@ -109,7 +114,7 @@ export const useApolloFactory = (options: Partial> = {}) => { setCurrentUser, setCurrentWorkspaceMember, setCurrentWorkspace, - setPreviousUrl, + setReturnToPath, enqueueErrorSnackBar, ]); diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index 7ea093a6b3..25e3863c09 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -4,16 +4,11 @@ import { } from '@/analytics/hooks/useEventTracker'; import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksOnAnyLocationChange'; import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState'; +import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths'; +import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths'; +import { useReturnToPath } from '@/auth/hooks/useReturnToPath'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useCallback, useEffect, useState } from 'react'; -import { - matchPath, - useLocation, - useNavigate, - useParams, -} from 'react-router-dom'; import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; @@ -35,6 +30,15 @@ import { PageFocusId } from '@/types/PageFocusId'; import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useStore } from 'jotai'; +import { useCallback, useEffect, useState } from 'react'; +import { + matchPath, + useLocation, + useNavigate, + useParams, +} from 'react-router-dom'; import { AppBasePath, AppPath, CommandMenuPages } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { AnalyticsType } from '~/generated-metadata/graphql'; @@ -42,7 +46,12 @@ import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffect import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; import { getPageTitleFromPath } from '~/utils/title-utils'; -import { useStore } from 'jotai'; + +const AUTH_AND_ONBOARDING_PATHS = [ + ...ONGOING_USER_CREATION_PATHS, + ...ONBOARDING_PATHS, + AppPath.ResetPassword, +]; // TODO: break down into smaller functions and / or hooks // - moved usePageChangeEffectNavigateLocation into dedicated hook @@ -99,6 +108,13 @@ export const PageChangeEffect = () => { const { closeCommandMenu } = useCommandMenu(); + const { saveReturnToPath, getReturnToPath, clearReturnToPath } = + useReturnToPath(); + + const isOnAuthOrOnboardingPage = AUTH_AND_ONBOARDING_PATHS.some((appPath) => + isMatchingLocation(location, appPath), + ); + const closeCommandMenuUnlessOnEditPage = useCallback(() => { const currentPage = store.get(commandMenuPageState.atom); if (currentPage === CommandMenuPages.NavigationMenuItemEdit) { @@ -133,13 +149,33 @@ export const PageChangeEffect = () => { isDefined(pageChangeEffectNavigateLocation) && isAppEffectRedirectEnabled ) { + if ( + pageChangeEffectNavigateLocation === AppPath.SignInUp && + !isOnAuthOrOnboardingPage + ) { + saveReturnToPath( + `${window.location.pathname}${window.location.search}${window.location.hash}`, + ); + } + + const consumedReturnToPath = + getReturnToPath() === pageChangeEffectNavigateLocation; + navigate(pageChangeEffectNavigateLocation); + + if (consumedReturnToPath) { + clearReturnToPath(); + } } }, [ navigate, pageChangeEffectNavigateLocation, initializeQueryParamState, isAppEffectRedirectEnabled, + isOnAuthOrOnboardingPage, + saveReturnToPath, + getReturnToPath, + clearReturnToPath, ]); useEffect(() => { diff --git a/packages/twenty-front/src/modules/app/hooks/useInitializeQueryParamState.ts b/packages/twenty-front/src/modules/app/hooks/useInitializeQueryParamState.ts index 100919e615..589ea24ba1 100644 --- a/packages/twenty-front/src/modules/app/hooks/useInitializeQueryParamState.ts +++ b/packages/twenty-front/src/modules/app/hooks/useInitializeQueryParamState.ts @@ -1,7 +1,9 @@ import { useCallback } from 'react'; import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState'; +import { returnToPathState } from '@/auth/states/returnToPathState'; import { type BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type'; +import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath'; import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue'; import deepEqual from 'deep-equal'; import { useStore } from 'jotai'; @@ -9,7 +11,7 @@ import { useStore } from 'jotai'; export const useInitializeQueryParamState = () => { const store = useStore(); const initializeQueryParamState = useCallback(() => { - const handlers = { + const handlers: Record void> = { billingCheckoutSession: (value: string) => { const billingCheckoutSession = store.get( billingCheckoutSessionState.atom, @@ -43,6 +45,11 @@ export const useInitializeQueryParamState = () => { ); } }, + returnToPath: (value: string) => { + if (isValidReturnToPath(value)) { + store.set(returnToPathState.atom, value); + } + }, }; const queryParams = new URLSearchParams(window.location.search); diff --git a/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts b/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts new file mode 100644 index 0000000000..46f1218ee3 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts @@ -0,0 +1,12 @@ +import { AppPath } from 'twenty-shared/types'; + +export const ONBOARDING_PATHS = [ + AppPath.CreateWorkspace, + AppPath.CreateProfile, + AppPath.SyncEmails, + AppPath.InviteTeam, + AppPath.PlanRequired, + AppPath.PlanRequiredSuccess, + AppPath.BookCallDecision, + AppPath.BookCall, +]; diff --git a/packages/twenty-front/src/modules/auth/constants/OngoingUserCreationPaths.ts b/packages/twenty-front/src/modules/auth/constants/OngoingUserCreationPaths.ts new file mode 100644 index 0000000000..db499b4c50 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/constants/OngoingUserCreationPaths.ts @@ -0,0 +1,8 @@ +import { AppPath } from 'twenty-shared/types'; + +export const ONGOING_USER_CREATION_PATHS = [ + AppPath.Invite, + AppPath.SignInUp, + AppPath.VerifyEmail, + AppPath.Verify, +]; diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index ffb63a2760..5f6d4ded6c 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -131,6 +131,8 @@ export const useAuth = () => { isCaptchaScriptLoadedState.atom, ); + store.set(isAppEffectRedirectEnabledState.atom, false); + sessionStorage.clear(); localStorage.clear(); @@ -158,6 +160,7 @@ export const useAuth = () => { setLastAuthenticateWorkspaceDomain(null); await resetToMockedMetadata(); navigate(AppPath.SignInUp); + store.set(isAppEffectRedirectEnabledState.atom, true); }, [ client, setLastAuthenticateWorkspaceDomain, diff --git a/packages/twenty-front/src/modules/auth/hooks/useReturnToPath.ts b/packages/twenty-front/src/modules/auth/hooks/useReturnToPath.ts new file mode 100644 index 0000000000..2f3825d326 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/hooks/useReturnToPath.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; + +import { returnToPathState } from '@/auth/states/returnToPathState'; +import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useStore } from 'jotai'; + +export const useReturnToPath = () => { + const store = useStore(); + const setReturnToPath = useSetAtomState(returnToPathState); + + const saveReturnToPath = useCallback( + (path: string) => { + if (!isValidReturnToPath(path)) { + return; + } + + setReturnToPath(path); + }, + [setReturnToPath], + ); + + const getReturnToPath = useCallback((): string | null => { + const currentReturnToPath = store.get(returnToPathState.atom); + + if ( + isNonEmptyString(currentReturnToPath) && + isValidReturnToPath(currentReturnToPath) + ) { + return currentReturnToPath; + } + + return null; + }, [store]); + + const clearReturnToPath = useCallback(() => { + setReturnToPath(''); + }, [setReturnToPath]); + + return { + saveReturnToPath, + getReturnToPath, + clearReturnToPath, + }; +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index 95e88d5601..30384c0916 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -1,4 +1,5 @@ import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState'; +import { returnToPathState } from '@/auth/states/returnToPathState'; import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; @@ -29,6 +30,7 @@ import { import { type AvailableWorkspace } from '~/generated-metadata/graphql'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNonEmptyString } from '@sniptt/guards'; const StyledContentContainer = styled(motion.div)` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -135,6 +137,7 @@ export const SignInUpGlobalScopeForm = () => { const { t } = useLingui(); const { form } = useSignInUpForm(); + const returnToPath = useAtomStateValue(returnToPathState); const getAvailableWorkspaceUrl = (availableWorkspace: AvailableWorkspace) => { const { pathname, searchParams } = getAvailableWorkspacePathAndSearchParams( @@ -145,7 +148,10 @@ export const SignInUpGlobalScopeForm = () => { return buildWorkspaceUrl( getWorkspaceUrl(availableWorkspace.workspaceUrls), pathname, - searchParams, + { + ...searchParams, + ...(isNonEmptyString(returnToPath) ? { returnToPath } : {}), + }, ); }; diff --git a/packages/twenty-front/src/modules/auth/states/previousUrlState.ts b/packages/twenty-front/src/modules/auth/states/returnToPathState.ts similarity index 55% rename from packages/twenty-front/src/modules/auth/states/previousUrlState.ts rename to packages/twenty-front/src/modules/auth/states/returnToPathState.ts index 6b54a28cfc..abe33f3d01 100644 --- a/packages/twenty-front/src/modules/auth/states/previousUrlState.ts +++ b/packages/twenty-front/src/modules/auth/states/returnToPathState.ts @@ -1,5 +1,6 @@ import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; -export const previousUrlState = createAtomState({ - key: 'previousUrlState', + +export const returnToPathState = createAtomState({ + key: 'returnToPathState', defaultValue: '', }); diff --git a/packages/twenty-front/src/modules/auth/utils/isValidReturnToPath.ts b/packages/twenty-front/src/modules/auth/utils/isValidReturnToPath.ts new file mode 100644 index 0000000000..67fa1db958 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/utils/isValidReturnToPath.ts @@ -0,0 +1,24 @@ +import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths'; +import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths'; +import { isNonEmptyString } from '@sniptt/guards'; +import { AppPath } from 'twenty-shared/types'; + +const extractPathPrefix = (appPath: string): string => appPath.split('/:')[0]; + +const EXCLUDED_PATH_PREFIXES = [ + ...ONGOING_USER_CREATION_PATHS, + ...ONBOARDING_PATHS, + AppPath.ResetPassword, +].map(extractPathPrefix); + +export const isValidReturnToPath = (path: string): boolean => { + if (!isNonEmptyString(path) || path === '/') { + return false; + } + + if (!path.startsWith('/') || path.startsWith('//')) { + return false; + } + + return !EXCLUDED_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)); +}; diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts index ced7328b83..89d4231ffe 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts @@ -1,13 +1,16 @@ import { useCallback } from 'react'; import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState'; +import { returnToPathState } from '@/auth/states/returnToPathState'; import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue'; +import { isNonEmptyString } from '@sniptt/guards'; import { useStore } from 'jotai'; export const useBuildSearchParamsFromUrlSyncedStates = () => { const store = useStore(); const buildSearchParamsFromUrlSyncedStates = useCallback(async () => { const billingCheckoutSession = store.get(billingCheckoutSessionState.atom); + const returnToPath = store.get(returnToPathState.atom); const output = { ...(billingCheckoutSession !== BILLING_CHECKOUT_SESSION_DEFAULT_VALUE @@ -15,6 +18,7 @@ export const useBuildSearchParamsFromUrlSyncedStates = () => { billingCheckoutSession: JSON.stringify(billingCheckoutSession), } : {}), + ...(isNonEmptyString(returnToPath) ? { returnToPath } : {}), }; return output; diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx index d3489f949e..56e80cca86 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx @@ -12,6 +12,9 @@ import { isDefined } from 'twenty-shared/utils'; import { type WorkspaceUrls } from '~/generated-metadata/graphql'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; +const getCurrentSearchParams = (): Record => + Object.fromEntries(new URLSearchParams(window.location.search)); + export const WorkspaceProviderEffect = () => { const { data: getPublicWorkspaceData } = useGetPublicWorkspaceDataByDomain(); @@ -48,6 +51,8 @@ export const WorkspaceProviderEffect = () => { ) { redirectToWorkspaceDomain( getWorkspaceUrl(getPublicWorkspaceData.workspaceUrls), + window.location.pathname, + getCurrentSearchParams(), ); } }, [ @@ -67,7 +72,11 @@ export const WorkspaceProviderEffect = () => { isDefined(lastAuthenticatedWorkspaceDomain?.workspaceUrl) ) { initializeQueryParamState(); - redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.workspaceUrl); + redirectToWorkspaceDomain( + lastAuthenticatedWorkspaceDomain.workspaceUrl, + window.location.pathname, + getCurrentSearchParams(), + ); } }, [ isMultiWorkspaceEnabled,