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,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));
};