Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/signin-prompt-select-account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

`<SignIn />` 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.
8 changes: 6 additions & 2 deletions packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> | void;
};

const SignInAccountSwitcherInternal = ({ onAddAccount }: SignInAccountSwitcherProps) => {
const card = useCardState();
const { userProfileUrl } = useEnvironment().displayConfig;
const { afterSignInUrl, path: signInPath, signInUrl, taskUrl } = useSignInContext();
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 23 additions & 3 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <SignInAccountSwitcher onAddAccount={() => navigate('.')} />;
}
return <SignInStartCard />;
};
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(<SignInAccountSwitcher onAddAccount={onAddAccount} />, { 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(<SignInAccountSwitcher />, { wrapper });
Expand Down
72 changes: 70 additions & 2 deletions packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');
Expand Down Expand Up @@ -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(<SignInStart />, { wrapper });
expectForm();
});

it('renders the account switcher with the prompt when signed-in sessions exist', async () => {
const { wrapper, fixtures } = await createFixturesWithPrompt(withSignedInSessions);
render(<SignInStart />, { 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(<SignInStart />, { 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(<SignInStart />, { 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(<SignInStart />, { wrapper });
vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([
{ id: 'sess_1' } as unknown as SignedInSessionResource,
]);
rerender(<SignInStart />);
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(<SignInStart />, { 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 => {
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/SignIn/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/router/HashRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};

Expand Down
31 changes: 29 additions & 2 deletions packages/ui/src/router/__tests__/HashRouter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<button
onClick={() => {
void router.navigate(to);
void router.navigate(to, { searchParams });
}}
>
{children}
Expand All @@ -47,6 +51,12 @@ const Tester = () => (
</Route>
<Route path='foo'>
<div id='bar'>Bar</div>
<Button
to='..'
searchParams={new URLSearchParams({ flag: '1' })}
>
Index with query
</Button>
</Route>
</HashRouter>
);
Expand Down Expand Up @@ -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(<Tester />);

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