Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/signin-multisession-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/ui': minor
'@clerk/shared': minor
---

Add a `multiSessionStart` prop to `<SignIn />`. 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.
4 changes: 4 additions & 0 deletions packages/shared/src/internal/clerk-js/constants.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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';
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1902,6 +1902,14 @@ export type SignInProps = RoutingOptions & {
* Optional for `oauth_<provider>` 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 &
Expand Down
12 changes: 10 additions & 2 deletions packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
});

Expand Down
33 changes: 31 additions & 2 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -797,6 +798,34 @@ const InstantPasswordRow = ({
);
};

const withRedirectToAccountSwitcher = <P extends object>(Component: ComponentType<P>) => {
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 <Component {...props} />;
};
HOC.displayName = `withRedirectToAccountSwitcher(${Component.displayName || Component.name || 'Component'})`;
return HOC;
};

export const SignInStart = withRedirectToSignInTask(
withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)),
withRedirectToAfterSignIn(withRedirectToAccountSwitcher(withCardStateProvider(SignInStartInternal))),
);
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -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(<SignInAccountSwitcher />, { 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(<SignInAccountSwitcher />, { 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 () => {
Expand Down
55 changes: 55 additions & 0 deletions packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,61 @@ describe('SignInStart', () => {
screen.getAllByText(/sign in to .*/i);
});

describe('multi-session start', () => {
const withSignedInSessions = (f: Parameters<Parameters<typeof createFixtures>[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(<SignInStart />, { 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(<SignInStart />, { 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(<SignInStart />, { 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(<SignInStart />, { 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(<SignInStart />, { wrapper });
expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
});
});

describe('Login Methods', () => {
it('enables login with email address', async () => {
const { wrapper } = await createFixtures(f => {
Expand Down
43 changes: 42 additions & 1 deletion packages/ui/src/router/__tests__/BaseRouter.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 (
<button
type='button'
data-testid='go'
onClick={() => void router.navigate('factor-one')}
>
Go
</button>
);
};

render(
<PathRouter
basePath='/sign-in'
preservedParams={PRESERVED_QUERYSTRING_PARAMS}
>
<Route path='factor-one'>
<div data-testid='factor-one'>Factor One</div>
</Route>
<Route index>
<NavigateTrigger />
</Route>
</PathRouter>,
);

act(() => {
screen.getByTestId('go').click();
});

await waitFor(() => expect(screen.getByTestId('factor-one')).toBeInTheDocument());
expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('__clerk_add_account=true'));
});
});
});
Loading