diff --git a/.changeset/kind-paws-swim.md b/.changeset/kind-paws-swim.md new file mode 100644 index 00000000000..95025976244 --- /dev/null +++ b/.changeset/kind-paws-swim.md @@ -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 diff --git a/integration/tests/session-tasks-multi-session.test.ts b/integration/tests/session-tasks-multi-session.test.ts index 7e923f6f957..3235d99fec1 100644 --- a/integration/tests/session-tasks-multi-session.test.ts +++ b/integration/tests/session-tasks-multi-session.test.ts @@ -61,6 +61,11 @@ 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); @@ -68,6 +73,11 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( // 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); diff --git a/integration/tests/sign-in-active-session-redirect.test.ts b/integration/tests/sign-in-active-session-redirect.test.ts new file mode 100644 index 00000000000..f65a7853a56 --- /dev/null +++ b/integration/tests/sign-in-active-session-redirect.test.ts @@ -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(); + }); + }, +); diff --git a/integration/tests/sign-in-single-session-mode.test.ts b/integration/tests/sign-in-single-session-mode.test.ts new file mode 100644 index 00000000000..821027d021d --- /dev/null +++ b/integration/tests/sign-in-single-session-mode.test.ts @@ -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('/'); + }); + }, +); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 6b0dd0cc8a9..69f574829b1 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -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') || ''; @@ -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 diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx index 5d6aad7857f..dc438b5f1d9 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx @@ -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(, { 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(, { 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(, { 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(, { wrapper }); + + await waitFor( + () => { + expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose'); + }, + { timeout: 100 }, + ); + }); + }); + }); }); diff --git a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx index dd3d37e9c1f..af63b10f6cd 100644 --- a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx @@ -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); };