Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions integration/tests/session-tasks-multi-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,23 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
// Create second user, to initiate a pending session
// Don't resolve task and switch to active session afterwards
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.po.signIn.getIdentifierInput().waitFor({ state: 'visible' });
await u.po.signIn.setIdentifier(user2.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(user2.password);
await u.po.signIn.continue();

// Sign-in again back with active session
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.po.signIn.getIdentifierInput().waitFor({ state: 'visible' });
await u.po.signIn.setIdentifier(user1.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(user1.password);
Expand Down
94 changes: 94 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,94 @@
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;
let fakeUser2: FakeUser;

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

fakeUser2 = u.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await u.services.users.createBapiUser(fakeUser2);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await fakeUser2.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('can sign in to second account after clicking "Add account" from /choose', 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').click();
await u.page.waitForURL(/sign-in$/);
await u.po.signIn.waitForMounted();

await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser2.email, password: fakeUser2.password });
await u.po.expect.toBeSignedIn();
});
},
);
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('/');
});
},
);
30 changes: 30 additions & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ function SignInStartInternal(): JSX.Element {
shouldStartWithPhoneNumberIdentifier ? 'phone_number' : identifierAttributes[0] || '',
);
const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false);
const hasInitializedRef = useRef(false);

const organizationTicket = getClerkQueryParam('__clerk_ticket') || '';
const clerkStatus = getClerkQueryParam('__clerk_status') || '';
Expand Down Expand Up @@ -181,6 +182,35 @@ function SignInStartInternal(): JSX.Element {
setShouldAutofocus(true);
};

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

hasInitializedRef.current = true;

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: ['user@clerk.com'],
});
});

// 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: ['user@clerk.com'],
});
});

// 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: ['user@clerk.com'],
});
});

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