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( + + +

Factor One
+ + + + + , + ); + + act(() => { + screen.getByTestId('go').click(); + }); + + await waitFor(() => expect(screen.getByTestId('factor-one')).toBeInTheDocument()); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('__clerk_add_account=true')); + }); + }); });