Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .changeset/fifty-terms-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@clerk/elements": minor
---


Introduce multi-session choose account step and associated actions/components.

Example:

```tsx
<SignIn.Step name='choose-session'>
<SignIn.SessionList>
<SignIn.SessionListItem>
{({ session }) => <>{session.identifier} | <SignIn.Action setActiveSession>Switch...</SignIn.Action></>}
</SignIn.SessionListItem>
</SignIn.SessionList>
</SignIn.Step>
```
15 changes: 15 additions & 0 deletions packages/elements/examples/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ export default function Home() {
<p className='m-0 max-w-[30ch] text-sm opacity-50'>Modal Playground</p>
</Link>

<SignedIn>
<Link
href='/sign-in/choose'
className='group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30'
>
<h2 className='mb-3 text-2xl font-semibold'>
Sessions{' '}
<span className='inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none'>
-&gt;
</span>
</h2>
<p className='m-0 max-w-[30ch] text-sm opacity-50'>Choose from Active Sessions via Multi-session support</p>
</Link>
</SignedIn>

<a
href='https://clerk.com/docs/custom-flows/overview#sign-in-flow'
className='group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,24 @@ export default function SignInPage() {
</div>
</SignIn.Step>

<SignIn.Step
name='choose-session'
className='flex w-96 flex-col items-center gap-6'
>
<H3>CHOOSE SESSION:</H3>

<SignIn.SessionList asChild>
<section>
<SignIn.SessionListItem asChild>
{({ session }) => (
<p>
{session.identifier} | <SignIn.Action setActiveSession>Switch...</SignIn.Action>{' '}
</p>
)}
</SignIn.SessionListItem>
</section>
</SignIn.SessionList>
</SignIn.Step>
<SignIn.Step
name='choose-strategy'
className='flex w-96 flex-col items-center gap-6'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ export default function SignUpPage() {
name='password'
/>

<CustomField
label='Phone Number'
name='phoneNumber'
/>

<CustomSubmit>Sign Up</CustomSubmit>
</SignUp.Step>

Expand Down
1 change: 1 addition & 0 deletions packages/elements/src/internals/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const SSO_CALLBACK_PATH_ROUTE = '/sso-callback';
export const CHOOSE_SESSION_PATH_ROUTE = '/choose';
export const MAGIC_LINK_VERIFY_PATH_ROUTE = '/verify';

export const SIGN_IN_DEFAULT_BASE_PATH =
Expand Down
23 changes: 21 additions & 2 deletions packages/elements/src/internals/machines/sign-in/router.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { NonReducibleUnknown } from 'xstate';
import { and, assign, enqueueActions, fromPromise, log, not, or, raise, sendTo, setup } from 'xstate';

import {
CHOOSE_SESSION_PATH_ROUTE,
ERROR_CODES,
ROUTING,
SIGN_IN_DEFAULT_BASE_PATH,
Expand All @@ -22,6 +23,7 @@ import type {
SignInRouterEvents,
SignInRouterNextEvent,
SignInRouterSchema,
SignInRouterSessionSetActiveEvent,
} from './router.types';
import { SignInStartMachine } from './start.machine';
import { SignInFirstFactorMachine, SignInSecondFactorMachine } from './verification.machine';
Expand Down Expand Up @@ -78,11 +80,12 @@ export const SignInRouterMachine = setup({
return;
}

const id = (event as SignInRouterSessionSetActiveEvent)?.id;
const lastActiveSessionId = context.clerk.client.lastActiveSessionId;
const createdSessionId = ((event as SignInRouterNextEvent)?.resource || context.clerk.client.signIn)
.createdSessionId;

const session = createdSessionId || lastActiveSessionId || null;
const session = id || createdSessionId || lastActiveSessionId || null;

const beforeEmit = () =>
context.router?.push(context.router?.searchParams().get('redirect_url') || context.clerk.buildAfterSignInUrl());
Expand Down Expand Up @@ -119,7 +122,7 @@ export const SignInRouterMachine = setup({
case ERROR_CODES.SAML_USER_ATTRIBUTE_MISSING:
case ERROR_CODES.OAUTH_EMAIL_DOMAIN_RESERVED_BY_SAML:
case ERROR_CODES.USER_LOCKED:
error = new ClerkElementsError(errorOrig.code, errorOrig.longMessage!);
error = new ClerkElementsError(errorOrig.code, errorOrig.longMessage || '');
break;
default:
error = new ClerkElementsError(
Expand Down Expand Up @@ -163,6 +166,7 @@ export const SignInRouterMachine = setup({
needsFirstFactor: and(['statusNeedsFirstFactor', isCurrentPath('/continue')]),
needsSecondFactor: and(['statusNeedsSecondFactor', isCurrentPath('/continue')]),
needsCallback: isCurrentPath(SSO_CALLBACK_PATH_ROUTE),
needsChooseSession: isCurrentPath(CHOOSE_SESSION_PATH_ROUTE),
needsNewPassword: and(['statusNeedsNewPassword', isCurrentPath('/new-password')]),

statusNeedsIdentifier: needsStatus('needs_identifier'),
Expand Down Expand Up @@ -278,6 +282,10 @@ export const SignInRouterMachine = setup({
guard: 'needsCallback',
target: 'Callback',
},
{
guard: 'needsChooseSession',
target: 'ChooseSession',
},
{
guard: 'isComplete',
actions: 'setActive',
Expand Down Expand Up @@ -577,6 +585,17 @@ export const SignInRouterMachine = setup({
],
},
},
ChooseSession: {
tags: ['step:choose-session'],
on: {
'SESSION.SET_ACTIVE': {
actions: {
type: 'setActive',
params: ({ event }) => ({ id: event.id }),
},
},
},
},
Error: {
tags: ['step:error'],
on: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const SignInRouterSteps = {
error: 'step:error',
forgotPassword: 'step:forgot-password',
resetPassword: 'step:reset-password',
chooseSession: 'step:choose-session',
chooseStrategy: 'step:choose-strategy',
} as const;

Expand Down Expand Up @@ -70,6 +71,7 @@ export type SignInRouterPasskeyEvent = { type: 'AUTHENTICATE.PASSKEY' };
export type SignInRouterPasskeyAutofillEvent = {
type: 'AUTHENTICATE.PASSKEY.AUTOFILL';
};
export type SignInRouterSessionSetActiveEvent = { type: 'SESSION.SET_ACTIVE'; id: string };

export interface SignInRouterInitEvent extends BaseRouterInput {
type: 'INIT';
Expand All @@ -95,6 +97,7 @@ export type SignInRouterEvents =
| SignInRouterResetStepEvent
| SignInVerificationFactorUpdateEvent
| SignInRouterLoadingEvent
| SignInRouterSessionSetActiveEvent
| SignInRouterSetClerkEvent
| SignInRouterSubmitEvent
| SignInRouterPasskeyEvent
Expand Down
23 changes: 11 additions & 12 deletions packages/elements/src/react/sign-in/action/action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,26 @@ import * as React from 'react';
import type { FormSubmitProps } from '~/react/common';
import { Submit } from '~/react/common';

import type { SignInNavigateElementKey, SignInNavigateProps } from './navigate';
import type { SignInNavigateProps } from './navigate';
import { SignInNavigate } from './navigate';
import type { SignInResendProps } from './resend';
import { SignInResend } from './resend';
import { SignInSetActiveSession } from './set-active-session';

export type SignInActionProps = { asChild?: boolean } & FormSubmitProps &
(
| ({
navigate: SignInNavigateProps['to'];
resend?: never;
setActiveSession?: never;
submit?: never;
} & Omit<SignInNavigateProps, 'to'>)
| { navigate?: never; resend?: never; submit: true }
| ({ navigate?: never; resend: true; submit?: never } & SignInResendProps)
| { navigate?: never; resend?: never; setActiveSession?: never; submit: true }
| { navigate?: never; resend?: never; setActiveSession: true; submit?: never }
| ({ navigate?: never; resend: true; setActiveSession?: never; submit?: never } & SignInResendProps)
);

export type SignInActionCompProps = React.ForwardRefExoticComponent<
Exclude<SignInActionProps, 'navigate'> & {
to: SignInNavigateElementKey;
} & React.RefAttributes<HTMLButtonElement>
>;

const SIGN_IN_ACTION_NAME = 'SignInAction';
const DISPLAY_NAME = 'SignInAction';

/**
* Perform various actions during the sign-in process. This component is used to navigate between steps, submit the form, or resend a verification codes.
Expand All @@ -45,7 +42,7 @@ const SIGN_IN_ACTION_NAME = 'SignInAction';
* <SignIn.Action resend>Resend</SignIn.Action>
*/
export const SignInAction = React.forwardRef<React.ElementRef<'button'>, SignInActionProps>((props, forwardedRef) => {
const { submit, navigate, resend, ...rest } = props;
const { submit, navigate, resend, setActiveSession, ...rest } = props;
let Comp: React.ForwardRefExoticComponent<any> | undefined;

if (submit) {
Expand All @@ -54,6 +51,8 @@ export const SignInAction = React.forwardRef<React.ElementRef<'button'>, SignInA
Comp = SignInNavigate;
} else if (resend) {
Comp = SignInResend;
} else if (setActiveSession) {
Comp = SignInSetActiveSession;
}

return Comp ? (
Expand All @@ -65,4 +64,4 @@ export const SignInAction = React.forwardRef<React.ElementRef<'button'>, SignInA
) : null;
});

SignInAction.displayName = SIGN_IN_ACTION_NAME;
SignInAction.displayName = DISPLAY_NAME;
8 changes: 4 additions & 4 deletions packages/elements/src/react/sign-in/action/navigate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { SignInRouterCtx } from '~/react/sign-in/context';

const SIGN_IN_NAVIGATE_NAME = 'SignInNavigate';
const SignInNavigationEventMap = {
start: `NAVIGATE.START`,
previous: `NAVIGATE.PREVIOUS`,
'choose-strategy': `NAVIGATE.CHOOSE_STRATEGY`,
'forgot-password': `NAVIGATE.FORGOT_PASSWORD`,
start: 'NAVIGATE.START',
previous: 'NAVIGATE.PREVIOUS',
'choose-strategy': 'NAVIGATE.CHOOSE_STRATEGY',
'forgot-password': 'NAVIGATE.FORGOT_PASSWORD',
} as const;

export type SignInNavigateElementKey = keyof typeof SignInNavigationEventMap;
Expand Down
58 changes: 58 additions & 0 deletions packages/elements/src/react/sign-in/action/set-active-session.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';

import type { SignInRouterSessionSetActiveEvent } from '~/internals/machines/sign-in';
import { SignInRouterCtx } from '~/react/sign-in/context';

import { useSignInActiveSessionContext } from '../choose-session/choose-session.hooks';

const DISPLAY_NAME = 'SignInSetActiveSession';

export type SignInSetActiveSessionElement = React.ElementRef<'button'>;
export type SignInSetActiveSessionProps = {
asChild?: boolean;
children: React.ReactNode;
};

/**
* Sets the active session to the session with the provided ID.
*
* @param {boolean} [asChild] - When `true`, the component will render its child and passes all props to it.
*
* @example
* <SignInSetActiveSession setActiveSession>
* t*****[email protected]
* </SignInSetActiveSession>
*/
export const SignInSetActiveSession = React.forwardRef<SignInSetActiveSessionElement, SignInSetActiveSessionProps>(
({ asChild, ...rest }, forwardedRef) => {
const actorRef = SignInRouterCtx.useActorRef();
const session = useSignInActiveSessionContext();

const Comp = asChild ? Slot : 'button';
const defaultProps = asChild ? {} : { type: 'button' as const };

const sendEvent = React.useCallback(() => {
const event: SignInRouterSessionSetActiveEvent = { type: 'SESSION.SET_ACTIVE', id: session.id };

if (actorRef.getSnapshot().can(event)) {
actorRef.send(event);
} else {
console.warn(
`Clerk: <SignIn.Action setActiveSession> is an invalid event. You can only choose an active session from <SignIn.Step name="choose-session">.`,
);
}
}, [actorRef, session.id]);

return (
<Comp
{...defaultProps}
{...rest}
onClick={sendEvent}
ref={forwardedRef}
/>
);
},
);

SignInSetActiveSession.displayName = DISPLAY_NAME;
Loading