feat: remember original URL and redirect after login (#18308)

## 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)
This commit is contained in:
Félix Malfait
2026-03-02 19:00:48 +01:00
committed by GitHub
parent 20a2c3836e
commit 6351c6c1c6
15 changed files with 351 additions and 45 deletions
@@ -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',
);
},
);
});
});
@@ -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,
);
});
});
@@ -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 (
@@ -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<Options<any>> = {}) => {
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<Options<any>> = {}) => {
!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<Options<any>> = {}) => {
setCurrentUser,
setCurrentWorkspaceMember,
setCurrentWorkspace,
setPreviousUrl,
setReturnToPath,
enqueueErrorSnackBar,
]);
@@ -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(() => {
@@ -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<string, (value: string) => 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);
@@ -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,
];
@@ -0,0 +1,8 @@
import { AppPath } from 'twenty-shared/types';
export const ONGOING_USER_CREATION_PATHS = [
AppPath.Invite,
AppPath.SignInUp,
AppPath.VerifyEmail,
AppPath.Verify,
];
@@ -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,
@@ -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,
};
};
@@ -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 } : {}),
},
);
};
@@ -1,5 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const previousUrlState = createAtomState<string>({
key: 'previousUrlState',
export const returnToPathState = createAtomState<string>({
key: 'returnToPathState',
defaultValue: '',
});
@@ -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));
};
@@ -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;
@@ -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<string, string> =>
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,