From bba73d2e33327651aba16fa4a6e1438f2005d9c1 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 10 Sep 2026 11:18:55 -0400 Subject: [PATCH] feat(ui): honor prompt=select_account on the SignIn URL On multi-session instances, a signed-in visitor arriving at sign-in with `prompt=select_account` sees the account switcher in place of the identifier form. "Add account" navigates to the index, which drops the prompt, so the form renders. Fixes the hash router dropping the query when navigating to the index route. Co-Authored-By: Claude Fable 5.1 --- .changeset/signin-prompt-select-account.md | 5 ++ .../SignIn/SignInAccountSwitcher.tsx | 8 ++- .../ui/src/components/SignIn/SignInStart.tsx | 26 ++++++- .../__tests__/SignInAccountSwitcher.test.tsx | 10 ++- .../SignIn/__tests__/SignInStart.test.tsx | 72 ++++++++++++++++++- packages/ui/src/components/SignIn/shared.ts | 4 ++ packages/ui/src/router/HashRouter.tsx | 4 +- .../src/router/__tests__/HashRouter.test.tsx | 31 +++++++- 8 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 .changeset/signin-prompt-select-account.md 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 ( ); @@ -106,4 +116,21 @@ describe('HashRouter', () => { expect(mockNavigate).toHaveBeenNthCalledWith(1, 'https://www.example.com/external'); }); }); + + describe('when navigating to the index route with a query', () => { + beforeEach(() => { + // @ts-ignore + window.location = new URL('https://www.example.com/hash#/foo?preserved=1'); + }); + + it('keeps the fragment readable as a URL', async () => { + render(); + + const button = screen.getByRole('button', { name: /Index with query/i }); + await userEvent.click(button); + + expect(window.location.hash).toBe('#/?flag=1&preserved=1'); + expect(screen.queryByText('Index')).toBeInTheDocument(); + }); + }); });