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