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-account-switcher-redirect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/ui': minor
'@clerk/shared': patch
---

On multi-session instances, visiting the sign-in start screen while accounts are already signed in now shows the account switcher instead of the identifier form, so flows arriving at sign-in (such as OAuth authorization) continue with an existing account instead of asking for the email again. Navigations that intend to add another account bypass the switcher via the `__clerk_add_account` search param — the switcher's "Add account" action sets it automatically and now also preserves `redirect_url`, so the newly added account continues where the flow left off.
6 changes: 6 additions & 0 deletions packages/shared/src/internal/clerk-js/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export const PRESERVED_QUERYSTRING_PARAMS = [
'sign_up_fallback_redirect_url',
];

/**
* Search param set when navigating to the sign-in start page to add another
* account. Bypasses the redirect to the account switcher that otherwise fires
* when signed-in sessions already exist on the client.
*/
export const CLERK_ADD_ACCOUNT = '__clerk_add_account';
export const CLERK_MODAL_STATE = '__clerk_modal_state';
export const CLERK_SYNCED = '__clerk_synced';
export const CLERK_SYNCED_STATUS = {
Expand Down
41 changes: 39 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 @@ -796,6 +797,42 @@ const InstantPasswordRow = ({
);
};

/**
* On multi-session instances, a visit to the sign-in start screen with
* accounts already signed in renders the account switcher (the `choose`
* route) instead of the identifier form. Navigations that intend to add
* another account opt out via [CLERK_ADD_ACCOUNT].
*
* Single-session instances are unaffected: `withRedirectToAfterSignIn`
* redirects their signed-in visitors before this guard runs.
*/
function withRedirectToAccountSwitcher<P extends object>(Component: ComponentType<P>): ComponentType<P> {
const HOC = (props: P) => {
const clerk = useClerk();
const { authConfig } = useEnvironment();
const { navigate, queryParams } = useRouter();

const shouldShowSwitcher =
!authConfig.singleSessionMode &&
clerk.client.signedInSessions.length > 0 &&
queryParams[CLERK_ADD_ACCOUNT] === undefined;

useEffect(() => {
if (shouldShowSwitcher) {
void navigate('choose');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldShowSwitcher]);

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,14 @@ 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(fixtures.router.navigate).toHaveBeenCalled();
expect(clerkWindowNavigate).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('__clerk_add_account=true'),
);
});

it('signs out when user clicks on "Sign out of all accounts"', async () => {
Expand Down
37 changes: 37 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,43 @@ describe('SignInStart', () => {
screen.getAllByText(/sign in to .*/i);
});

describe('account switcher redirect', () => {
it('redirects to the account switcher when signed-in sessions exist on a multi-session instance', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withMultiSessionMode();
f.withUser({ email_addresses: ['test1@clerk.com'] });
});
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 add-account param is set', async () => {
const { createFixtures: createFixturesWithAddAccount } = bindCreateFixtures('SignIn', {
router: { queryParams: { __clerk_add_account: 'true' } },
});
const { wrapper, fixtures } = await createFixturesWithAddAccount(f => {
f.withEmailAddress();
f.withMultiSessionMode();
f.withUser({ email_addresses: ['test1@clerk.com'] });
});
render(<SignInStart />, { wrapper });
screen.getAllByText(/sign in to .*/i);
expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
});

it('does not redirect when no signed-in sessions exist', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withMultiSessionMode();
});
render(<SignInStart />, { wrapper });
screen.getAllByText(/sign in to .*/i);
expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
});
});

describe('Login Methods', () => {
it('enables login with email address', async () => {
const { wrapper } = await createFixtures(f => {
Expand Down
13 changes: 12 additions & 1 deletion packages/ui/src/components/UserButton/useMultisessionActions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CLERK_ADD_ACCOUNT } from '@clerk/shared/internal/clerk-js/constants';
import { navigateIfTaskExists } from '@clerk/shared/internal/clerk-js/sessionTasks';
import { useClerk, usePortalRoot } from '@clerk/shared/react';
import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types';
Expand Down Expand Up @@ -102,7 +103,17 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
};

const handleAddAccountClicked = () => {
clerkWindowNavigate(clerk, opts.signInUrl || window.location.href);
const url = new URL(opts.signInUrl || window.location.href, window.location.origin);
// Keep an in-flight destination (e.g. an OAuth consent screen) so the
// newly added account continues where the flow left off.
const redirectUrl = new URLSearchParams(window.location.search).get('redirect_url');
if (redirectUrl && !url.searchParams.has('redirect_url')) {
url.searchParams.set('redirect_url', redirectUrl);
}
// The sign-in start screen redirects to the account switcher when
// signed-in sessions exist; this param tells it to show the form instead.
url.searchParams.set(CLERK_ADD_ACCOUNT, 'true');
clerkWindowNavigate(clerk, url.toString());
return sleep(2000);
};

Expand Down
Loading