Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kind-paws-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Detect when a user already has an active session in multi-session app and redirect to /choose subroute
95 changes: 95 additions & 0 deletions integration/tests/sign-in-active-session-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
'sign in redirect with active session @generic @nextjs',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
});

test('redirects to /sign-in/choose when visiting /sign-in with active session in multi-session mode', async ({
page,
context,
}) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.page.goToAppHome();
await u.po.signIn.goTo();
await u.page.waitForURL(/sign-in\/choose/);
await u.page.waitForSelector('text=Choose an account');
});

test('shows active session in account switcher with option to add account', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.po.signIn.goTo();
await u.page.waitForURL(/sign-in\/choose/);
await u.po.signIn.waitForMounted();
await u.page.getByText('Add account').waitFor({ state: 'visible' });
await u.page.getByText('Choose an account').waitFor({ state: 'visible' });
});

test('shows sign-in form when no active sessions exist', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.page.context().clearCookies();
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.page.waitForSelector('text=/email address|username|phone/i');
await u.page.waitForURL(/sign-in$/);
});

test('clicking "Add account" from /choose shows sign-in form without redirecting back', async ({
page,
context,
}) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.po.signIn.goTo();
await u.page.waitForURL(/sign-in\/choose/);

await u.page.getByText('Add account').click();
await u.page.waitForURL(/sign-in$/);
await u.po.signIn.waitForMounted();
await u.page.waitForSelector('text=/email address|username|phone/i');

await u.page.waitForTimeout(500);
const currentUrl = u.page.url();
await u.page.waitForTimeout(500);
const urlAfterWait = u.page.url();

if (currentUrl !== urlAfterWait) {
throw new Error(`URL changed from ${currentUrl} to ${urlAfterWait}, indicating a redirect loop`);
}
});
},
);
79 changes: 79 additions & 0 deletions integration/tests/sign-in-single-session-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { FakeUser } from '@clerk/testing/playwright';
import { test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

/**
* Tests for single-session mode behavior using the withBilling environment
* which is configured for single-session mode in the Clerk Dashboard.
*/
testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })(
'sign in with active session in single-session mode @generic @nextjs',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
});

test('redirects to afterSignIn URL when visiting /sign-in with active session in single-session mode', async ({
page,
context,
}) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();
await u.page.waitForAppUrl('/');

await u.po.signIn.goTo();
await u.page.waitForAppUrl('/');
await u.po.expect.toBeSignedIn();
});

test('does NOT show account switcher in single-session mode', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/sign-in/choose');
await u.page.waitForAppUrl('/');
});

test('shows sign-in form when no active session in single-session mode', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.page.context().clearCookies();
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.page.waitForSelector('text=/email address|username|phone/i');
await u.page.waitForURL(/sign-in$/);
});

test('can sign in normally when not already authenticated in single-session mode', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.page.context().clearCookies();
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();
await u.page.waitForAppUrl('/');
});
},
);
27 changes: 27 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,33 @@ function SignInStartInternal(): JSX.Element {
setShouldAutofocus(true);
};

/**
* Redirect to account switcher if user already has active sessions in multi-session mode
*/
useEffect(() => {
if (organizationTicket) {
return;
}

const urlParams = new URLSearchParams(window.location.search);
const isAddingAccount = urlParams.has('__clerk_add_account');

if (isAddingAccount) {
urlParams.delete('__clerk_add_account');
const newSearch = urlParams.toString();
const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '');
window.history.replaceState({}, '', newUrl);
return;
}

const hasActiveSessions = (clerk.client?.signedInSessions?.length ?? 0) > 0;
const isMultiSessionMode = !authConfig.singleSessionMode;

if (hasActiveSessions && isMultiSessionMode) {
void navigate('choose');
}
}, [clerk.client?.signedInSessions, authConfig.singleSessionMode, navigate, organizationTicket]);

// switch to the phone input (if available) if a "+" is entered
// (either by the browser or the user)
// this does not work in chrome as it does not fire the change event and the value is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -590,4 +590,114 @@ describe('SignInStart', () => {
);
});
});

describe('Active session redirect', () => {
describe('multi-session mode', () => {
it('redirects to /choose when user has active sessions', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withMultiSessionMode();
f.withUser({
email_addresses: ['[email protected]'],
});
});

// Mock active sessions using spyOn
vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([
{
id: 'sess_123',
user: fixtures.clerk.user,
status: 'active',
} as any,
]);

render(<SignInStart />, { wrapper });

await waitFor(() => {
expect(fixtures.router.navigate).toHaveBeenCalledWith('choose');
});
});

it('redirects to /choose when user has multiple active sessions', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withMultiSessionMode();
f.withUser({
email_addresses: ['[email protected]'],
});
});

// Mock multiple active sessions using spyOn
vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([
{
id: 'sess_123',
user: fixtures.clerk.user,
status: 'active',
} as any,
{
id: 'sess_456',
user: { id: 'user_456' },
status: 'active',
} as any,
]);

render(<SignInStart />, { wrapper });

await waitFor(() => {
expect(fixtures.router.navigate).toHaveBeenCalledWith('choose');
});
});

it('does NOT redirect when user has no active sessions', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withMultiSessionMode();
});

// No active sessions using spyOn
vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([]);

render(<SignInStart />, { wrapper });

await waitFor(
() => {
expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
},
{ timeout: 100 },
);

screen.getByText(/email address/i);
});
});

describe('single-session mode', () => {
it('does NOT redirect to /choose when user has active session in single-session mode', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withUser({
email_addresses: ['[email protected]'],
});
});

vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([
{
id: 'sess_123',
user: fixtures.clerk.user,
status: 'active',
} as any,
]);

fixtures.environment.authConfig.singleSessionMode = true;

render(<SignInStart />, { wrapper });

await waitFor(
() => {
expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
},
{ timeout: 100 },
);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
};

const handleAddAccountClicked = () => {
windowNavigate(opts.signInUrl || window.location.href);
const url = new URL(opts.signInUrl || window.location.href, window.location.origin);
url.searchParams.set('__clerk_add_account', '1');
windowNavigate(url.toString());
return sleep(2000);
};

Expand Down
Loading