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(); + }); + }); });