diff --git a/.changeset/signin-prompt-select-account.md b/.changeset/signin-prompt-select-account.md
new file mode 100644
index 00000000000..44981717c1b
--- /dev/null
+++ b/.changeset/signin-prompt-select-account.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': minor
+---
+
+`` now honors `prompt=select_account` on its URL. On multi-session instances, a signed-in visitor arriving with it sees the account switcher instead of the identifier form, so flows that route through sign-in (such as OAuth authorization) can continue with an existing account. "Add account" opens the form. Single-session instances and visitors with no session are unaffected.
diff --git a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
index 3540530b6c7..3e577fa9a89 100644
--- a/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
+++ b/packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
@@ -12,7 +12,11 @@ import { Add, ArrowRight } from '../../icons';
import { SignOutAllActions } from '../UserButton/SessionActions';
import { useMultisessionActions } from '../UserButton/useMultisessionActions';
-const SignInAccountSwitcherInternal = () => {
+type SignInAccountSwitcherProps = {
+ onAddAccount?: () => Promise | void;
+};
+
+const SignInAccountSwitcherInternal = ({ onAddAccount }: SignInAccountSwitcherProps) => {
const card = useCardState();
const { userProfileUrl } = useEnvironment().displayConfig;
const { afterSignInUrl, path: signInPath, signInUrl, taskUrl } = useSignInContext();
@@ -75,7 +79,7 @@ const SignInAccountSwitcherInternal = () => {
iconElementId={descriptors.accountSwitcherActionButtonIcon.setId('addAccount')}
icon={Add}
label={localizationKeys('signIn.accountSwitcher.action__addAccount')}
- onClick={handleAddAccountClicked}
+ onClick={onAddAccount ?? handleAddAccountClicked}
iconSx={t => ({
width: t.sizes.$9,
height: t.sizes.$6,
diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx
index a95040b8465..504a027d1d6 100644
--- a/packages/ui/src/components/SignIn/SignInStart.tsx
+++ b/packages/ui/src/components/SignIn/SignInStart.tsx
@@ -42,9 +42,12 @@ import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer';
import { navigateOnSignInProtectGate } from './handleProtectCheck';
import {
hasMultipleEnterpriseConnections,
+ SIGN_IN_PROMPT_PARAM,
+ SIGN_IN_PROMPT_SELECT_ACCOUNT,
SIGN_IN_RESET_PASSWORD_INTENT_PARAM,
useHandleAuthenticateWithPasskey,
} from './shared';
+import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard';
import { SignInSocialButtons } from './SignInSocialButtons';
import {
@@ -797,6 +800,23 @@ const InstantPasswordRow = ({
);
};
-export const SignInStart = withRedirectToSignInTask(
- withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)),
-);
+const SignInStartCard = withRedirectToSignInTask(withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)));
+
+export const SignInStart = () => {
+ const clerk = useClerk();
+ const { authConfig } = useEnvironment();
+ const { navigate, queryParams } = useRouter();
+ // Snapshot on mount: the sign-in POST adds a session before setActive navigates; keep the form until then.
+ const [hadSignedInSessions] = useState(() => clerk.client.signedInSessions.length > 0);
+
+ const showAccountSwitcher =
+ queryParams[SIGN_IN_PROMPT_PARAM] === SIGN_IN_PROMPT_SELECT_ACCOUNT &&
+ !authConfig.singleSessionMode &&
+ hadSignedInSessions;
+
+ if (showAccountSwitcher) {
+ // Navigating to the index drops `prompt` (not a preserved param), so the form renders.
+ return navigate('.')} />;
+ }
+ return ;
+};
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx
index 54a8cd799de..27b8ce48dc8 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInAccountSwitcher.test.tsx
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { bindCreateFixtures } from '@/test/create-fixtures';
import { render } from '@/test/utils';
@@ -44,6 +44,14 @@ describe('SignInAccountSwitcher', () => {
expect(fixtures.router.navigate).toHaveBeenCalled();
});
+ it('uses the given "Add account" handler when one is passed', async () => {
+ const onAddAccount = vi.fn();
+ const { wrapper } = await createFixtures(initConfig);
+ const { userEvent, getByText } = render(, { wrapper });
+ await userEvent.click(getByText('Add account'));
+ expect(onAddAccount).toHaveBeenCalled();
+ });
+
it('signs out when user clicks on "Sign out of all accounts"', async () => {
const { wrapper, fixtures } = await createFixtures(initConfig);
const { userEvent, getByText } = render(, { wrapper });
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
index 01bd5d4e3a0..4e6d31544fc 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
@@ -1,7 +1,7 @@
import { ClerkAPIResponseError, ClerkWebAuthnError } from '@clerk/shared/error';
import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants';
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
-import type { SignInResource } from '@clerk/shared/types';
+import type { SignedInSessionResource, SignInResource } from '@clerk/shared/types';
import { waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -12,7 +12,7 @@ import { CardStateProvider } from '@/ui/elements/contexts';
import { OptionsProvider } from '../../../contexts';
import { AppearanceProvider } from '../../../customizables';
-import { SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from '../shared';
+import { SIGN_IN_PROMPT_PARAM, SIGN_IN_PROMPT_SELECT_ACCOUNT, SIGN_IN_RESET_PASSWORD_INTENT_PARAM } from '../shared';
import { SignInStart } from '../SignInStart';
const { createFixtures } = bindCreateFixtures('SignIn');
@@ -66,6 +66,74 @@ describe('SignInStart', () => {
screen.getAllByText(/sign in to .*/i);
});
+ describe('prompt=select_account', () => {
+ const { createFixtures: createFixturesWithPrompt } = bindCreateFixtures('SignIn', {
+ router: { queryParams: { [SIGN_IN_PROMPT_PARAM]: SIGN_IN_PROMPT_SELECT_ACCOUNT } },
+ });
+ const withSignedInSessions = createFixtures.config(f => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ f.withUser({ email_addresses: ['test1@clerk.com'] });
+ });
+ const expectForm = () => {
+ screen.getAllByText(/sign in to .*/i);
+ expect(screen.queryByText('Add account')).toBeNull();
+ };
+
+ it('renders the identifier form without the prompt even when signed-in sessions exist', async () => {
+ const { wrapper } = await createFixtures(withSignedInSessions);
+ render(, { wrapper });
+ expectForm();
+ });
+
+ it('renders the account switcher with the prompt when signed-in sessions exist', async () => {
+ const { wrapper, fixtures } = await createFixturesWithPrompt(withSignedInSessions);
+ render(, { wrapper });
+ screen.getByText('Add account');
+ expect(screen.queryByText(/sign in to .*/i)).toBeNull();
+ expect(fixtures.router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('navigates to the index without the prompt when "Add account" is clicked', async () => {
+ const { wrapper, fixtures } = await createFixturesWithPrompt(withSignedInSessions);
+ const { userEvent } = render(, { wrapper });
+ await userEvent.click(screen.getByText('Add account'));
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('.');
+ });
+
+ it('renders the identifier form with the prompt when no signed-in sessions exist', async () => {
+ const { wrapper } = await createFixturesWithPrompt(f => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ });
+ render(, { wrapper });
+ expectForm();
+ });
+
+ it('keeps the identifier form when a session appears after mount', async () => {
+ const { wrapper, fixtures } = await createFixturesWithPrompt(f => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ });
+ const { rerender } = render(, { wrapper });
+ vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([
+ { id: 'sess_1' } as unknown as SignedInSessionResource,
+ ]);
+ rerender();
+ expectForm();
+ });
+
+ it('ignores the prompt in single-session mode', async () => {
+ const { wrapper, fixtures } = await createFixturesWithPrompt(f => {
+ f.withEmailAddress();
+ f.withUser({ email_addresses: ['test1@clerk.com'] });
+ });
+ render(, { wrapper });
+ expect(screen.queryByText('Add account')).toBeNull();
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('/');
+ });
+ });
+
describe('Login Methods', () => {
it('enables login with email address', async () => {
const { wrapper } = await createFixtures(f => {
diff --git a/packages/ui/src/components/SignIn/shared.ts b/packages/ui/src/components/SignIn/shared.ts
index ec23c4fff39..fdff9a624ee 100644
--- a/packages/ui/src/components/SignIn/shared.ts
+++ b/packages/ui/src/components/SignIn/shared.ts
@@ -16,6 +16,10 @@ import { navigateOnSignInProtectGate } from './handleProtectCheck';
/** Search param set when navigating from the start page "Forgot password?" action. */
export const SIGN_IN_RESET_PASSWORD_INTENT_PARAM = '__clerk_reset_password';
+/** OIDC `prompt` param; FAPI forwards `select_account` on the sign-in URL when the user should pick an existing account. */
+export const SIGN_IN_PROMPT_PARAM = 'prompt';
+export const SIGN_IN_PROMPT_SELECT_ACCOUNT = 'select_account';
+
/**
* @param onSecondFactor - invoked when the passkey attempt resolves to a second factor.
* @param protectCheckPath - route to the protect-check card relative to the caller's mount.
diff --git a/packages/ui/src/router/HashRouter.tsx b/packages/ui/src/router/HashRouter.tsx
index 1d1e2e8b452..0de46158a18 100644
--- a/packages/ui/src/router/HashRouter.tsx
+++ b/packages/ui/src/router/HashRouter.tsx
@@ -18,7 +18,9 @@ export const HashRouter = ({ preservedParams, children }: HashRouterProps): JSX.
if (!toURL) {
return;
}
- window.location.hash = stripOrigin(toURL).substring(1 + hashRouterBase.length);
+ const hash = stripOrigin(toURL).substring(1 + hashRouterBase.length);
+ // The index route with a query would otherwise become `#?x`, which is not read as a fragment URL.
+ window.location.hash = hash.startsWith('?') ? '/' + hash : hash;
return Promise.resolve();
};
diff --git a/packages/ui/src/router/__tests__/HashRouter.test.tsx b/packages/ui/src/router/__tests__/HashRouter.test.tsx
index ab6ee1c17a8..b0720013e09 100644
--- a/packages/ui/src/router/__tests__/HashRouter.test.tsx
+++ b/packages/ui/src/router/__tests__/HashRouter.test.tsx
@@ -25,12 +25,16 @@ vi.mock('@clerk/shared/react', () => {
};
});
-const Button = ({ to, children }: React.PropsWithChildren<{ to: string }>) => {
+const Button = ({
+ to,
+ searchParams,
+ children,
+}: React.PropsWithChildren<{ to: string; searchParams?: URLSearchParams }>) => {
const router = useRouter();
return (