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, `'switcher'` starts a signed-in visitor on 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. Defaults to `'form'`; ignored in single-session mode. "Add account" from the switcher now preserves the current `redirect_url`; from the switcher and the `<UserButton />` it opens the sign-in form directly instead of returning to the switcher.
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
9 changes: 9 additions & 0 deletions packages/shared/src/internal/clerk-js/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logger } from '../../logger';
import type { SignUpResource } from '../../types';
import { camelToSnake } from '../../underscore';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '../../url';
import { CLERK_ADD_ACCOUNT } from './constants';
import { joinPaths } from './path';
import { getQueryParams } from './querystring';

Expand Down Expand Up @@ -156,6 +157,14 @@ export function buildURL(params: BuildURLParams, options: BuildURLOptions<boolea
return url;
}

/**
* @internal
* Flags a sign-in navigation as "add account" so the start screen renders the form, not the account switcher.
*/
export const buildAddAccountUrl = (base: string): string => {
return buildURL({ base, hashSearchParams: { [CLERK_ADD_ACCOUNT]: 'true' } }, { stringify: true });
};

export function toURL(url: string | URL): URL {
return new URL(url.toString(), window.location.origin);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1902,6 +1902,15 @@ 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.
* "Add account" navigates to the sign-in page, so in a modal it leaves the current page.
*
* @default 'form'
*/
multiSessionStart?: 'form' | 'switcher';
} & TransferableOption &
SignUpForceRedirectUrl &
SignUpFallbackRedirectUrl &
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ 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();
const { handleSignOutAllClicked, handleSessionClicked, signedInSessions, handleAddAccountClicked } =
useMultisessionActions({
taskUrl,
navigateAfterSignOut,
afterSwitchSessionUrl: afterSignInUrl,
userProfileUrl,
signInUrl: signInPath ?? signInUrl,
signInUrl,
user: undefined,
});

Expand Down
36 changes: 33 additions & 3 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 All @@ -28,6 +29,7 @@ import type { SignInStartIdentifier } from '../../common';
import {
getIdentifierControlDisplayValues,
groupIdentifiers,
withRedirect,
withRedirectToAfterSignIn,
withRedirectToSignInTask,
} from '../../common';
Expand All @@ -38,6 +40,7 @@ import { useLoadingStatus } from '../../hooks';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods';
import { useRouter } from '../../router';
import type { AvailableComponentProps } from '../../types';
import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer';
import { navigateOnSignInProtectGate } from './handleProtectCheck';
import {
Expand Down Expand Up @@ -797,6 +800,33 @@ const InstantPasswordRow = ({
);
};

export const SignInStart = withRedirectToSignInTask(
withRedirectToAfterSignIn(withCardStateProvider(SignInStartInternal)),
const withRedirectToAccountSwitcher = <P extends AvailableComponentProps>(Component: ComponentType<P>) => {
const displayName = Component.displayName || Component.name || 'Component';
Component.displayName = displayName;

const HOC = (props: P) => {
const clerk = useClerk();
const { multiSessionStart } = useSignInContext();
const { 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);

return withRedirect(
Component,
(_, environment) =>
multiSessionStart === 'switcher' &&
!environment?.authConfig.singleSessionMode &&
hadSignedInSessions &&
queryParams[CLERK_ADD_ACCOUNT] === undefined,
() => 'choose',
undefined,
{ replace: true },
)(props);
};
HOC.displayName = `withRedirectToAccountSwitcher(${displayName})`;
return HOC;
};

export const SignInStart = withRedirectToAccountSwitcher(
withRedirectToSignInTask(withRedirectToAfterSignIn(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).toHaveBeenLastCalledWith(
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
89 changes: 87 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 } from '@clerk/shared/error';
import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants';
import { CAPTCHA_ELEMENT_ID, CLERK_ADD_ACCOUNT } 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 Down Expand Up @@ -66,6 +66,91 @@ describe('SignInStart', () => {
screen.getAllByText(/sign in to .*/i);
});

describe('multi-session start', () => {
const withSignedInSessions = createFixtures.config(f => {
f.withEmailAddress();
f.withMultiSessionMode();
f.withUser({ email_addresses: ['test1@clerk.com'] });
});
const navigations = (fixtures: Awaited<ReturnType<typeof createFixtures>>['fixtures']) =>
fixtures.router.navigate.mock.calls.map(([to]) => to);
const { createFixtures: createFixturesWithAddAccount } = bindCreateFixtures('SignIn', {
router: { queryParams: { [CLERK_ADD_ACCOUNT]: 'true' } },
});

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(navigations(fixtures)).not.toContain('choose');
});

it('replaces the route with 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', { replace: true }));
expect(fixtures.router.navigate).toHaveBeenCalledTimes(1);
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(navigations(fixtures)).not.toContain('choose');
});

it('keeps the identifier form when a session appears after mount', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withEmailAddress();
f.withMultiSessionMode();
});
props.setProps({ multiSessionStart: 'switcher' });
const { rerender } = render(<SignInStart />, { wrapper });
vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([
{ id: 'sess_1' } as unknown as SignedInSessionResource,
]);
rerender(<SignInStart />);
screen.getAllByText(/sign in to .*/i);
expect(navigations(fixtures)).not.toContain('choose');
});

it('renders the identifier form when the add-account param is set', async () => {
const { wrapper, fixtures, props } = await createFixturesWithAddAccount(withSignedInSessions);
props.setProps({ multiSessionStart: 'switcher' });
render(<SignInStart />, { wrapper });
screen.getAllByText(/sign in to .*/i);
expect(navigations(fixtures)).not.toContain('choose');
});

it('carries the add-account param through the OAuth callback URL', async () => {
const { wrapper, fixtures } = await createFixturesWithAddAccount(f => {
f.withMultiSessionMode();
f.withSocialProvider({ provider: 'google' });
});
const { userEvent } = render(<SignInStart />, { wrapper });
await userEvent.click(screen.getByText('Continue with Google'));
expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalledWith(
expect.objectContaining({ redirectUrl: expect.stringContaining('__clerk_add_account=true') }),
);
});

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(navigations(fixtures)).not.toContain('choose');
});
});

describe('Login Methods', () => {
it('enables login with email address', async () => {
const { wrapper } = await createFixtures(f => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { UNSAFE_PortalProvider } from '@clerk/shared/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, screen, waitFor } from '@/test/utils';
import { clerkWindowNavigate } from '@/ui/utils/windowNavigate';

import { UserButton } from '../';

vi.mock('@/ui/utils/windowNavigate', () => ({ clerkWindowNavigate: vi.fn() }));

const { createFixtures } = bindCreateFixtures('UserButton');

describe('UserButton', () => {
Expand Down Expand Up @@ -87,8 +90,6 @@ describe('UserButton', () => {
expect(fixtures.router.navigate).toHaveBeenCalledWith('/');
});

it.todo('navigates to sign in url when "Add account" is clicked');

describe('UserButton with PortalProvider', () => {
it('passes getContainer to openUserProfile when wrapped in PortalProvider', async () => {
const container = document.createElement('div');
Expand Down Expand Up @@ -157,6 +158,17 @@ describe('UserButton', () => {
expect(getByText('First3 Last3')).toBeDefined();
});

it('navigates to the sign-in URL with the add-account param when "Add account" is clicked', async () => {
const { wrapper } = await createFixtures(initConfig);
const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
await userEvent.click(getByRole('button', { name: 'Open user menu' }));
await userEvent.click(getByText('Add account'));
expect(clerkWindowNavigate).toHaveBeenLastCalledWith(
expect.anything(),
expect.stringContaining('__clerk_add_account=true'),
);
});

it('changes the active session when clicking another session', async () => {
const { wrapper, fixtures } = await createFixtures(initConfig);
fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { navigateIfTaskExists } from '@clerk/shared/internal/clerk-js/sessionTasks';
import { buildAddAccountUrl } from '@clerk/shared/internal/clerk-js/url';
import { useClerk, usePortalRoot } from '@clerk/shared/react';
import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types';

Expand Down Expand Up @@ -102,7 +103,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
};

const handleAddAccountClicked = () => {
clerkWindowNavigate(clerk, opts.signInUrl || window.location.href);
clerkWindowNavigate(clerk, buildAddAccountUrl(opts.signInUrl || window.location.href));
return sleep(2000);
};

Expand Down
13 changes: 11 additions & 2 deletions packages/ui/src/contexts/components/SignIn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { SIGN_IN_INITIAL_VALUE_KEYS, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants';
import {
CLERK_ADD_ACCOUNT,
SIGN_IN_INITIAL_VALUE_KEYS,
SIGN_UP_MODES,
} from '@clerk/shared/internal/clerk-js/constants';
import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls';
import { getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks';
import { buildURL } from '@clerk/shared/internal/clerk-js/url';
Expand Down Expand Up @@ -101,7 +105,12 @@ export const useSignInContext = (): SignInContextType => {
signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true });
waitlistUrl = buildURL({ base: waitlistUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true });

const authQueryString = redirectUrls.toSearchParams().toString();
const authSearchParams = redirectUrls.toSearchParams();
if (queryParams[CLERK_ADD_ACCOUNT]) {
// Survives the OAuth / email-link round trip so a failed add-account attempt lands back on the form.
authSearchParams.set(CLERK_ADD_ACCOUNT, queryParams[CLERK_ADD_ACCOUNT]);
}
const authQueryString = authSearchParams.toString();

// Callback routes owned by the SignIn tree are always SignIn-rooted — including the combined-flow
// branches mounted at `create/sso-callback` and `create/verify` under the SignIn component
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ describe('useUserButtonModel', () => {
expect(navigate).not.toHaveBeenCalled();

fireEvent.click(screen.getByText('add-account'));
expect(navigate).toHaveBeenCalledWith('/sign-in');
expect(navigate).toHaveBeenCalledWith('http://localhost:3000/sign-in#/?__clerk_add_account=true');
});

it('navigates to a create-organization URL when one is given', () => {
Expand Down
Loading
Loading