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
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
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
32 changes: 21 additions & 11 deletions packages/ui/src/components/SignIn/SignInAccountSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CLERK_ADD_ACCOUNT } from '@clerk/shared/internal/clerk-js/constants';

import { Action, Actions } from '@/ui/elements/Actions';
import { Card } from '@/ui/elements/Card';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
Expand All @@ -9,23 +11,31 @@ import { withRedirectToAfterSignIn } from '../../common';
import { useEnvironment, useSignInContext, useSignOutContext } from '../../contexts';
import { Col, descriptors, Flow, localizationKeys } from '../../customizables';
import { Add, ArrowRight } from '../../icons';
import { useRouter } from '../../router';
import { SignOutAllActions } from '../UserButton/SessionActions';
import { useMultisessionActions } from '../UserButton/useMultisessionActions';

const SignInAccountSwitcherInternal = () => {
type SignInAccountSwitcherProps = {
// Route of the sign-in start screen relative to where the switcher is mounted.
addAccountPath?: string;
};

const SignInAccountSwitcherInternal = ({ addAccountPath = '..' }: SignInAccountSwitcherProps) => {
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,
user: undefined,
});
const { navigate } = useRouter();
const { handleSignOutAllClicked, handleSessionClicked, signedInSessions } = useMultisessionActions({
taskUrl,
navigateAfterSignOut,
afterSwitchSessionUrl: afterSignInUrl,
userProfileUrl,
signInUrl,
user: undefined,
});
const handleAddAccountClicked = () =>
navigate(addAccountPath, { searchParams: new URLSearchParams({ [CLERK_ADD_ACCOUNT]: 'true' }) });

return (
<Flow.Part part='accountSwitcher'>
Expand Down
24 changes: 20 additions & 4 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 Down Expand Up @@ -45,6 +45,7 @@ import {
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 +798,21 @@ 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 { 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);

const showSwitcher =
multiSessionStart === 'switcher' &&
!authConfig.singleSessionMode &&
hadSignedInSessions &&
queryParams[CLERK_ADD_ACCOUNT] === undefined;

return showSwitcher ? <SignInAccountSwitcher addAccountPath='.' /> : <SignInStartCard />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,20 @@ 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 () => {
it('navigates to the sign-in start screen with the add-account param when "Add account" is clicked', async () => {
const { wrapper, fixtures } = await createFixtures(initConfig);
const { userEvent, getByText } = render(<SignInAccountSwitcher />, { wrapper });
await userEvent.click(getByText('Add account'));
expect(fixtures.router.navigate).toHaveBeenCalled();
expect(fixtures.router.navigate).toHaveBeenCalledWith('..', {
searchParams: new URLSearchParams({ __clerk_add_account: 'true' }),
});
});

it('navigates to the given start screen path when rendered in place', async () => {
const { wrapper, fixtures } = await createFixtures(initConfig);
const { userEvent, getByText } = render(<SignInAccountSwitcher addAccountPath='.' />, { wrapper });
await userEvent.click(getByText('Add account'));
expect(fixtures.router.navigate).toHaveBeenCalledWith('.', expect.anything());
});

it('signs out when user clicks on "Sign out of all accounts"', async () => {
Expand Down
91 changes: 89 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 { 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,93 @@ 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 { createFixtures: createFixturesWithAddAccount } = bindCreateFixtures('SignIn', {
router: { queryParams: { [CLERK_ADD_ACCOUNT]: 'true' } },
});
const expectForm = () => {
screen.getAllByText(/sign in to .*/i);
expect(screen.queryByText('Add account')).toBeNull();
};
const expectSwitcher = () => {
screen.getByText('Add account');
expect(screen.queryByText(/sign in to .*/i)).toBeNull();
};

it('renders the identifier form when the prop is unset and signed-in sessions exist', async () => {
const { wrapper } = await createFixtures(withSignedInSessions);
render(<SignInStart />, { wrapper });
expectForm();
});

it('renders 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 });
expectSwitcher();
expect(fixtures.router.navigate).not.toHaveBeenCalled();
});

it('renders the identifier form when the prop is "switcher" and no signed-in sessions exist', async () => {
const { wrapper, props } = await createFixtures(f => {
f.withEmailAddress();
f.withMultiSessionMode();
});
props.setProps({ multiSessionStart: 'switcher' });
render(<SignInStart />, { wrapper });
expectForm();
});

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

it('renders the identifier form when the add-account param is set', async () => {
const { wrapper, props } = await createFixturesWithAddAccount(withSignedInSessions);
props.setProps({ multiSessionStart: 'switcher' });
render(<SignInStart />, { wrapper });
expectForm();
});

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('ignores the prop 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(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
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
5 changes: 4 additions & 1 deletion packages/ui/src/mosaic/user-button/user-button.model.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks';
import { buildAddAccountUrl } from '@clerk/shared/internal/clerk-js/url';
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react';
import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types';
Expand Down Expand Up @@ -292,7 +293,9 @@ export function useUserButtonModel(
onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined,
// Covers both restricted instances and users at their creation limit.
onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined,
onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()),
onAddAccount: singleSessionMode
? undefined
: () => void router.navigate(buildAddAccountUrl(clerk.buildSignInUrl())),
onAcceptSuggestion: async suggestionId => {
const suggestion = suggestionData.find(s => s.id === suggestionId);
try {
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
Loading
Loading