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:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user