diff --git a/.changeset/signin-multisession-start.md b/.changeset/signin-multisession-start.md
new file mode 100644
index 00000000000..271f3639c84
--- /dev/null
+++ b/.changeset/signin-multisession-start.md
@@ -0,0 +1,6 @@
+---
+'@clerk/ui': minor
+'@clerk/shared': minor
+---
+
+Add a `multiSessionStart` prop to ``. On multi-session instances, setting it to `'switcher'` starts a signed-in visitor on the account switcher (listing the signed-in accounts, with "Add account" and "Sign out of all accounts") instead of the identifier form, so flows that route through sign-in such as OAuth authorization can continue with an existing account. The default `'form'` keeps the current behavior, and the prop is ignored in single-session mode. The switcher's "Add account" action now also preserves the current `redirect_url`, so the newly added account continues where the flow left off.
diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts
index c11db68f590..5ba7ebb8680 100644
--- a/packages/shared/src/internal/clerk-js/constants.ts
+++ b/packages/shared/src/internal/clerk-js/constants.ts
@@ -1,5 +1,8 @@
import type { SignUpModes } from '../../types';
+// Set on add-account navigations so the sign-in start screen renders the identifier form instead of the account switcher.
+export const CLERK_ADD_ACCOUNT = '__clerk_add_account';
+
// TODO: Do we still have a use for this or can we simply preserve all params?
export const PRESERVED_QUERYSTRING_PARAMS = [
'redirect_url',
@@ -9,6 +12,7 @@ export const PRESERVED_QUERYSTRING_PARAMS = [
'sign_in_fallback_redirect_url',
'sign_up_force_redirect_url',
'sign_up_fallback_redirect_url',
+ CLERK_ADD_ACCOUNT,
];
export const CLERK_MODAL_STATE = '__clerk_modal_state';
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index bf5325b7e62..1613f57f32c 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -1902,6 +1902,14 @@ export type SignInProps = RoutingOptions & {
* Optional for `oauth_` or `enterprise_sso` strategies. The value to pass to the [OIDC prompt parameter](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=prompt,reauthentication%20and%20consent.) in the generated OAuth redirect URL.
*/
oidcPrompt?: string;
+ /**
+ * On multi-session instances, where a signed-in visitor lands when opening the sign-in component.
+ * `'form'` renders the identifier form. `'switcher'` renders the account switcher listing the signed-in accounts,
+ * with "Add account" and "Sign out of all accounts". Ignored in single-session mode.
+ *
+ * @default 'form'
+ */
+ multiSessionStart?: 'form' | 'switcher';
} & TransferableOption &
SignUpForceRedirectUrl &
SignUpFallbackRedirectUrl &
diff --git a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
index 3540530b6c7..31e6953d602 100644
--- a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
+++ b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
@@ -1,3 +1,6 @@
+import { CLERK_ADD_ACCOUNT } from '@clerk/shared/internal/clerk-js/constants';
+import { buildURL } from '@clerk/shared/internal/clerk-js/url';
+
import { Action, Actions } from '@/ui/elements/Actions';
import { Card } from '@/ui/elements/Card';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
@@ -15,15 +18,20 @@ import { useMultisessionActions } from '../UserButton/useMultisessionActions';
const SignInAccountSwitcherInternal = () => {
const card = useCardState();
const { userProfileUrl } = useEnvironment().displayConfig;
- const { afterSignInUrl, path: signInPath, signInUrl, taskUrl } = useSignInContext();
+ const { afterSignInUrl, signInUrl, taskUrl } = useSignInContext();
const { navigateAfterSignOut } = useSignOutContext();
+ // signInUrl already carries the current query (incl. redirect_url) in the fragment, which both routers read.
+ const addAccountUrl = buildURL(
+ { base: signInUrl, hashSearchParams: { [CLERK_ADD_ACCOUNT]: 'true' } },
+ { stringify: true },
+ );
const { handleSignOutAllClicked, handleSessionClicked, signedInSessions, handleAddAccountClicked } =
useMultisessionActions({
taskUrl,
navigateAfterSignOut,
afterSwitchSessionUrl: afterSignInUrl,
userProfileUrl,
- signInUrl: signInPath ?? signInUrl,
+ signInUrl: addAccountUrl,
user: undefined,
});
diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx
index a95040b8465..5d6e6f20c1c 100644
--- a/packages/ui/src/components/SignIn/SignInStart.tsx
+++ b/packages/ui/src/components/SignIn/SignInStart.tsx
@@ -1,5 +1,5 @@
import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode';
-import { ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants';
+import { CLERK_ADD_ACCOUNT, ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants';
import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors';
import { getClerkQueryParam, removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams';
import { useClerk } from '@clerk/shared/react';
@@ -11,6 +11,7 @@ import type {
SignInResource,
} from '@clerk/shared/types';
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn';
+import type { ComponentType } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Card } from '@/ui/elements/Card';
@@ -797,6 +798,34 @@ const InstantPasswordRow = ({
);
};
+const withRedirectToAccountSwitcher =
(Component: ComponentType
) => {
+ const HOC = (props: P) => {
+ const clerk = useClerk();
+ const { authConfig } = useEnvironment();
+ const { multiSessionStart } = useSignInContext();
+ const { navigate, queryParams } = useRouter();
+
+ const shouldShowSwitcher =
+ multiSessionStart === 'switcher' &&
+ !authConfig.singleSessionMode &&
+ clerk.client.signedInSessions.length > 0 &&
+ queryParams[CLERK_ADD_ACCOUNT] === undefined;
+
+ useEffect(() => {
+ if (shouldShowSwitcher) {
+ void navigate('choose');
+ }
+ }, [shouldShowSwitcher, navigate]);
+
+ if (shouldShowSwitcher) {
+ return null;
+ }
+ return ;
+ };
+ HOC.displayName = `withRedirectToAccountSwitcher(${Component.displayName || Component.name || 'Component'})`;
+ return HOC;
+};
+
export const SignInStart = withRedirectToSignInTask(
- withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)),
+ withRedirectToAfterSignIn(withRedirectToAccountSwitcher(withCardStateProvider(SignInStartInternal))),
);
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx
index 54a8cd799de..dc4c154c9be 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx
@@ -1,10 +1,13 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { bindCreateFixtures } from '@/test/create-fixtures';
import { render } from '@/test/utils';
+import { clerkWindowNavigate } from '@/ui/utils/windowNavigate';
import { SignInAccountSwitcher } from '../SignInAccountSwitcher';
+vi.mock('@/ui/utils/windowNavigate', () => ({ clerkWindowNavigate: vi.fn() }));
+
const { createFixtures } = bindCreateFixtures('SignIn');
const initConfig = createFixtures.config(f => {
@@ -36,12 +39,27 @@ describe('SignInAccountSwitcher', () => {
expect(fixtures.clerk.setActive).toHaveBeenCalled();
});
- // this one uses the windowNavigate method. we need to mock it correctly
- it.skip('navigates to SignInStart component if user clicks on "Add account" button', async () => {
- const { wrapper, fixtures } = await createFixtures(initConfig);
+ it('navigates to sign-in with the add-account param when "Add account" is clicked', async () => {
+ const { wrapper } = await createFixtures(initConfig);
+ const { userEvent, getByText } = render(, { wrapper });
+ await userEvent.click(getByText('Add account'));
+ expect(clerkWindowNavigate).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.stringContaining('__clerk_add_account=true'),
+ );
+ });
+
+ it('keeps the current redirect_url when "Add account" is clicked', async () => {
+ const { createFixtures: createFixturesWithRedirect } = bindCreateFixtures('SignIn', {
+ router: { queryParams: { redirect_url: 'https://example.com/consent' } },
+ });
+ const { wrapper } = await createFixturesWithRedirect(initConfig);
const { userEvent, getByText } = render(, { wrapper });
await userEvent.click(getByText('Add account'));
- expect(fixtures.router.navigate).toHaveBeenCalled();
+ expect(clerkWindowNavigate).toHaveBeenLastCalledWith(
+ expect.anything(),
+ expect.stringMatching(/redirect_url=https%3A%2F%2Fexample\.com%2Fconsent.*__clerk_add_account=true/),
+ );
});
it('signs out when user clicks on "Sign out of all accounts"', async () => {
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
index 36a0b24858b..51bf03efee8 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
@@ -66,6 +66,61 @@ describe('SignInStart', () => {
screen.getAllByText(/sign in to .*/i);
});
+ describe('multi-session start', () => {
+ const withSignedInSessions = (f: Parameters[0]>[0]) => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ f.withUser({ email_addresses: ['test1@clerk.com'] });
+ };
+
+ it('renders the identifier form when the prop is unset and signed-in sessions exist', async () => {
+ const { wrapper, fixtures } = await createFixtures(withSignedInSessions);
+ render(, { wrapper });
+ screen.getAllByText(/sign in to .*/i);
+ expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
+ });
+
+ it('redirects to the account switcher when the prop is "switcher" and signed-in sessions exist', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(withSignedInSessions);
+ props.setProps({ multiSessionStart: 'switcher' });
+ render(, { wrapper });
+ await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('choose'));
+ expect(screen.queryByText(/sign in to .*/i)).toBeNull();
+ });
+
+ it('renders the identifier form when the prop is "switcher" and no signed-in sessions exist', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ });
+ props.setProps({ multiSessionStart: 'switcher' });
+ render(, { wrapper });
+ screen.getAllByText(/sign in to .*/i);
+ expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
+ });
+
+ it('renders the identifier form when the add-account param is set', async () => {
+ const { createFixtures: createFixturesWithAddAccount } = bindCreateFixtures('SignIn', {
+ router: { queryParams: { __clerk_add_account: 'true' } },
+ });
+ const { wrapper, fixtures, props } = await createFixturesWithAddAccount(withSignedInSessions);
+ props.setProps({ multiSessionStart: 'switcher' });
+ render(, { wrapper });
+ screen.getAllByText(/sign in to .*/i);
+ expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
+ });
+
+ it('does not redirect to the account switcher in single-session mode', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({ email_addresses: ['test1@clerk.com'] });
+ });
+ props.setProps({ multiSessionStart: 'switcher' });
+ render(, { wrapper });
+ expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
+ });
+ });
+
describe('Login Methods', () => {
it('enables login with email address', async () => {
const { wrapper } = await createFixtures(f => {
diff --git a/packages/ui/src/router/__tests__/BaseRouter.test.tsx b/packages/ui/src/router/__tests__/BaseRouter.test.tsx
index 901ca2ea078..e48ff2c2b07 100644
--- a/packages/ui/src/router/__tests__/BaseRouter.test.tsx
+++ b/packages/ui/src/router/__tests__/BaseRouter.test.tsx
@@ -1,5 +1,6 @@
+import { PRESERVED_QUERYSTRING_PARAMS } from '@clerk/shared/internal/clerk-js/constants';
import type { Clerk } from '@clerk/shared/types';
-import { act, render, screen } from '@testing-library/react';
+import { act, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -195,4 +196,44 @@ describe('BaseRouter basePath guard', () => {
expect(screen.getByTestId('factor-one')).toBeInTheDocument();
});
});
+
+ describe('preserved query params', () => {
+ it('carries __clerk_add_account across an internal navigation', async () => {
+ setWindowLocation('https://www.example.com/sign-in?__clerk_add_account=true');
+
+ const NavigateTrigger = () => {
+ const router = useRouter();
+ return (
+
+ );
+ };
+
+ render(
+
+
+