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:
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user