From 61553649a879b4fed51b7aeddea507de229202f5 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 14 Aug 2024 13:53:15 -0400 Subject: [PATCH 01/10] feat(elements): Add active session selection [SDKI-290] --- .../app/sign-in/[[...sign-in]]/page.tsx | 36 +++++++ .../elements/src/internals/constants/index.ts | 1 + .../machines/sign-in/router.machine.ts | 23 +++- .../machines/sign-in/router.types.ts | 3 + .../src/react/sign-in/action/action.tsx | 6 +- .../sign-in/action/set-active-session.tsx | 55 ++++++++++ .../src/react/sign-in/choose-session.tsx | 102 ++++++++++++++++++ packages/elements/src/react/sign-in/index.ts | 1 + packages/elements/src/react/sign-in/step.tsx | 24 +++-- .../react/utils/is-valid-component-type.ts | 5 + 10 files changed, 241 insertions(+), 15 deletions(-) create mode 100644 packages/elements/src/react/sign-in/action/set-active-session.tsx create mode 100644 packages/elements/src/react/sign-in/choose-session.tsx create mode 100644 packages/elements/src/react/utils/is-valid-component-type.ts diff --git a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx index db8d344480f..c83c348430d 100644 --- a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx +++ b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx @@ -239,6 +239,42 @@ export default function SignInPage() { + +

CHOOSE SESSION:

+ + +
+ {({ session }) =>

{session.id}

}
+
+
+ + +
+ {({ session }) => <>{session.id}} +
+
+ + + {({ session }) => <>{session.id}} + + + + {({ session }) => {session.id}} + + + + {({ session }) =>

{session.id}

}
+
+
context.router?.push(context.router?.searchParams().get('redirect_url') || context.clerk.buildAfterSignInUrl()); @@ -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( @@ -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'), @@ -278,6 +282,10 @@ export const SignInRouterMachine = setup({ guard: 'needsCallback', target: 'Callback', }, + { + guard: 'needsChooseSession', + target: 'ChooseSession', + }, { guard: 'isComplete', actions: 'setActive', @@ -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: { diff --git a/packages/elements/src/internals/machines/sign-in/router.types.ts b/packages/elements/src/internals/machines/sign-in/router.types.ts index 9d1b5e333f4..6e095f634c6 100644 --- a/packages/elements/src/internals/machines/sign-in/router.types.ts +++ b/packages/elements/src/internals/machines/sign-in/router.types.ts @@ -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; @@ -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'; @@ -95,6 +97,7 @@ export type SignInRouterEvents = | SignInRouterResetStepEvent | SignInVerificationFactorUpdateEvent | SignInRouterLoadingEvent + | SignInRouterSessionSetActiveEvent | SignInRouterSetClerkEvent | SignInRouterSubmitEvent | SignInRouterPasskeyEvent diff --git a/packages/elements/src/react/sign-in/action/action.tsx b/packages/elements/src/react/sign-in/action/action.tsx index da05b33bad8..e260dbf9fe4 100644 --- a/packages/elements/src/react/sign-in/action/action.tsx +++ b/packages/elements/src/react/sign-in/action/action.tsx @@ -13,10 +13,12 @@ export type SignInActionProps = { asChild?: boolean } & FormSubmitProps & | ({ navigate: SignInNavigateProps['to']; resend?: never; + setActiveSession?: never; submit?: never; } & Omit) - | { 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< diff --git a/packages/elements/src/react/sign-in/action/set-active-session.tsx b/packages/elements/src/react/sign-in/action/set-active-session.tsx new file mode 100644 index 00000000000..e5cc2b20892 --- /dev/null +++ b/packages/elements/src/react/sign-in/action/set-active-session.tsx @@ -0,0 +1,55 @@ +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'; + +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 + * + * t*****m@clerk.dev + * + */ +export const SignInSetActiveSession = React.forwardRef( + ({ asChild, ...rest }, forwardedRef) => { + const actorRef = SignInRouterCtx.useActorRef(); + + const Comp = asChild ? Slot : 'button'; + const defaultProps = asChild ? {} : { type: 'button' as const }; + + const sendEvent = React.useCallback(() => { + const event: SignInRouterSessionSetActiveEvent = { type: 'SESSION.SET_ACTIVE', id: 'abc123' }; + + if (actorRef.getSnapshot().can(event)) { + actorRef.send(event); + } else { + console.warn( + `Clerk: is an invalid event. You can only choose an active session from .`, + ); + } + }, [actorRef]); + + return ( + + ); + }, +); + +SignInSetActiveSession.displayName = DISPLAY_NAME; diff --git a/packages/elements/src/react/sign-in/choose-session.tsx b/packages/elements/src/react/sign-in/choose-session.tsx new file mode 100644 index 00000000000..b89bc80fbbe --- /dev/null +++ b/packages/elements/src/react/sign-in/choose-session.tsx @@ -0,0 +1,102 @@ +import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; + +import { useActiveTags } from '../hooks'; +import { createContextForDomValidation } from '../utils/create-context-for-dom-validation'; +import { isValidComponentType } from '../utils/is-valid-component-type'; +import { SignInRouterCtx } from './context'; + +// ----------------------------------- TYPES ------------------------------------ +// +export type SignInChooseSessionProps = React.HTMLAttributes; +export type SignInSessionListProps = React.HTMLAttributes & { asChild?: boolean }; +export type SignInSessionListItemProps = Omit, 'children'> & { + asChild?: boolean; + children: (session: any) => React.ReactNode; +}; + +// ---------------------------------- CONTEXT ----------------------------------- + +export const SignInChooseSessionCtx = createContextForDomValidation('SignInChooseSessionCtx'); +const SignInActiveSessionContext = React.createContext(null); + +// ----------------------------------- HOOKS ------------------------------------ + +function useSignInActiveSessionContext() { + const ctx = React.useContext(SignInActiveSessionContext); + + if (!ctx) { + throw new Error('SignInActiveSessionContext must be used within a SessionList/SignInSessionListItem'); + } + + return ctx; +} + +function useSignInActiveSessionList() { + return SignInRouterCtx.useSelector(state => + state.context.clerk.client.activeSessions.map(s => ({ + id: s.id, + ...s.publicUserData, + })), + ); +} + +// --------------------------------- COMPONENTS --------------------------------- + +export function SignInChooseSession({ children, ...props }: SignInChooseSessionProps) { + const routerRef = SignInRouterCtx.useActorRef(); + const activeState = useActiveTags(routerRef, 'step:choose-session'); + + return activeState ? ( + +
{children}
+
+ ) : null; +} + +export function SignInSessionList({ asChild, children, ...props }: SignInSessionListProps) { + const sessions = useSignInActiveSessionList(); + + if (!children || !sessions?.length) { + return null; + } + + if (React.Children.count(children) > 1) { + return React.Children.only(null); + } + + if (asChild && !isValidComponentType(children, SignInSessionListItem)) { + // TODO: Update error message + throw new Error('asChild cannot be used with SessionListItem as the direct child'); + } + + if (!React.isValidElement(children)) { + // TODO: Update error message + throw new Error('children must be a valid React element'); + } + + const newChildren = asChild ? (children.props.children as React.ReactNode) : children; + const childrenWithCtx = sessions.map(session => { + return ( + + {newChildren} + + ); + }); + + if (asChild) { + return {React.cloneElement(children, undefined, childrenWithCtx)}; + } + + return
    {childrenWithCtx}
; +} + +export function SignInSessionListItem({ asChild, children, ...props }: SignInSessionListItemProps) { + const session = useSignInActiveSessionContext(); + const Comp = asChild ? Slot : 'li'; + + return {children({ session })}; +} diff --git a/packages/elements/src/react/sign-in/index.ts b/packages/elements/src/react/sign-in/index.ts index dfc75220c28..c609ef4b388 100644 --- a/packages/elements/src/react/sign-in/index.ts +++ b/packages/elements/src/react/sign-in/index.ts @@ -6,6 +6,7 @@ export { SignInStep as Step } from './step'; export { SignInAction as Action } from './action'; export { SignInPasskey as Passkey } from './passkey'; export { SignInSupportedStrategy as SupportedStrategy } from './choose-strategy'; +export { SignInSessionList as SessionList, SignInSessionListItem as SessionListItem } from './choose-session'; export { SignInFirstFactor as FirstFactor, diff --git a/packages/elements/src/react/sign-in/step.tsx b/packages/elements/src/react/sign-in/step.tsx index 36e04cbe7f1..c01a08b02bf 100644 --- a/packages/elements/src/react/sign-in/step.tsx +++ b/packages/elements/src/react/sign-in/step.tsx @@ -3,19 +3,17 @@ import { eventComponentMounted } from '@clerk/shared/telemetry'; import { ClerkElementsRuntimeError } from '~/internals/errors'; -import type { SignInChooseStrategyProps } from './choose-strategy'; -import { SignInChooseStrategy, SignInForgotPassword } from './choose-strategy'; -import type { SignInResetPasswordProps } from './reset-password'; -import { SignInResetPassword } from './reset-password'; -import type { SignInStartProps } from './start'; -import { SignInStart } from './start'; -import type { SignInVerificationsProps } from './verifications'; -import { SignInVerifications } from './verifications'; +import { SignInChooseSession, type SignInChooseSessionProps } from './choose-session'; +import { SignInChooseStrategy, type SignInChooseStrategyProps, SignInForgotPassword } from './choose-strategy'; +import { SignInResetPassword, type SignInResetPasswordProps } from './reset-password'; +import { SignInStart, type SignInStartProps } from './start'; +import { SignInVerifications, type SignInVerificationsProps } from './verifications'; export const SIGN_IN_STEPS = { start: 'start', verifications: 'verifications', 'choose-strategy': 'choose-strategy', + 'choose-session': 'choose-session', 'forgot-password': 'forgot-password', 'reset-password': 'reset-password', } as const; @@ -27,7 +25,8 @@ export type SignInStepProps = | StepWithProps<'start', SignInStartProps> | StepWithProps<'verifications', SignInVerificationsProps> | StepWithProps<'choose-strategy' | 'forgot-password', SignInChooseStrategyProps> - | StepWithProps<'reset-password', SignInResetPasswordProps>; + | StepWithProps<'reset-password', SignInResetPasswordProps> + | StepWithProps<'choose-session', SignInChooseSessionProps>; /** * Render different steps of the sign-in flow. Initially the `'start'` step is rendered. Once a sign-in attempt has been created, `'verifications'` will be displayed. If during that verification step the user decides to choose a different method of signing in or verifying, the `'choose-strategy'` step will be displayed. @@ -43,6 +42,7 @@ export type SignInStepProps = * * * + * * */ export function SignInStep(props: SignInStepProps) { @@ -51,9 +51,9 @@ export function SignInStep(props: SignInStepProps) { clerk.telemetry?.record(eventComponentMounted('Elements_SignInStep', { name: props.name })); switch (props.name) { - case SIGN_IN_STEPS['start']: + case SIGN_IN_STEPS.start: return ; - case SIGN_IN_STEPS['verifications']: + case SIGN_IN_STEPS.verifications: return ; case SIGN_IN_STEPS['choose-strategy']: return ; @@ -61,6 +61,8 @@ export function SignInStep(props: SignInStepProps) { return ; case SIGN_IN_STEPS['reset-password']: return ; + case SIGN_IN_STEPS['choose-session']: + return ; default: throw new ClerkElementsRuntimeError(`Invalid step name. Use: ${Object.keys(SIGN_IN_STEPS).join(',')}.`); } diff --git a/packages/elements/src/react/utils/is-valid-component-type.ts b/packages/elements/src/react/utils/is-valid-component-type.ts new file mode 100644 index 00000000000..f932f0514e9 --- /dev/null +++ b/packages/elements/src/react/utils/is-valid-component-type.ts @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export function isValidComponentType(child: React.ReactNode, type: any): child is React.ReactElement { + return React.isValidElement(child) && child.type === type; +} From 99e97f34f5afb317503b30f96a5cf9b43a9e221a Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Thu, 15 Aug 2024 12:48:29 -0400 Subject: [PATCH 02/10] test(elements): Add tests to choose-session --- .../app/sign-in/[[...sign-in]]/page.tsx | 26 +--- .../__tests__/choose-session.test.tsx | 136 ++++++++++++++++++ .../choose-session/choose-session.hooks.ts | 31 ++++ .../{ => choose-session}/choose-session.tsx | 42 ++---- .../react/sign-in/choose-session/index.tsx | 2 + 5 files changed, 182 insertions(+), 55 deletions(-) create mode 100644 packages/elements/src/react/sign-in/choose-session/__tests__/choose-session.test.tsx create mode 100644 packages/elements/src/react/sign-in/choose-session/choose-session.hooks.ts rename packages/elements/src/react/sign-in/{ => choose-session}/choose-session.tsx (69%) create mode 100644 packages/elements/src/react/sign-in/choose-session/index.tsx diff --git a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx index c83c348430d..b606af13596 100644 --- a/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx +++ b/packages/elements/examples/nextjs/app/sign-in/[[...sign-in]]/page.tsx @@ -245,35 +245,11 @@ export default function SignInPage() { >

CHOOSE SESSION:

- +
{({ session }) =>

{session.id}

}
- - -
- {({ session }) => <>{session.id}} -
-
- - - {({ session }) => <>{session.id}} - - - - {({ session }) => {session.id}} - - - - {({ session }) =>

{session.id}

}
-
{ + beforeAll(() => { + jest.spyOn(Hooks, 'useSignInChooseSessionIsActive').mockImplementation(() => true); + jest.spyOn(Hooks, 'useSignInActiveSessionList').mockImplementation(() => [ + { + id: 'abc123', + firstName: 'firstName', + lastName: 'lastName', + imageUrl: 'https://foo.bar/baz.jpg', + hasImage: true, + identifier: 'support@clerk.com', + }, + ]); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should render default ul/li elements', () => { + const { container } = render( + + {({ session }) => session.id} + , + ); + + expect(container).toMatchInlineSnapshot(` +
+
    +
  • + abc123 +
  • +
+
+ `); + }); + + it('should render default ul/li elements with children', () => { + const { container } = render( + + {({ session }) =>

{session.id}

}
+
, + ); + + expect(container).toMatchInlineSnapshot(` +
+
    +
  • +

    + abc123 +

    +
  • +
+
+ `); + }); + + it('should render default ul with asChild children', () => { + const { container } = render( + + {({ session }) =>

{session.id}

}
+
, + ); + + expect(container).toMatchInlineSnapshot(` +
+
    +

    + abc123 +

    +
+
+ `); + }); + + it('should render asChold list with default li', () => { + const { container } = render( + +
+ {({ session }) => session.id} +
+
, + ); + + expect(container).toMatchInlineSnapshot(` +
+
+
  • + abc123 +
  • +
    +
    + `); + }); + + it('should render asChild list and items', () => { + const { container } = render( + +
    + {({ session }) =>

    {session.id}

    }
    +
    +
    , + ); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + abc123 +

    +
    +
    + `); + }); + + it('should not allow asChild with a direct child of SessionListItem', () => { + const consoleErrorFn = jest.spyOn(console, 'error').mockImplementation(() => jest.fn()); + + expect(() => + render( + + {() => 'foo'} + , + ), + ).toThrow('asChild cannot be used with SessionListItem as the direct child'); + + consoleErrorFn.mockRestore(); + }); +}); diff --git a/packages/elements/src/react/sign-in/choose-session/choose-session.hooks.ts b/packages/elements/src/react/sign-in/choose-session/choose-session.hooks.ts new file mode 100644 index 00000000000..3a2ced86acf --- /dev/null +++ b/packages/elements/src/react/sign-in/choose-session/choose-session.hooks.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { useActiveTags } from '../../hooks'; +import { SignInRouterCtx } from '../context'; + +export const SignInActiveSessionContext = React.createContext(null); + +export function useSignInActiveSessionContext() { + const ctx = React.useContext(SignInActiveSessionContext); + + if (!ctx) { + throw new Error('SignInActiveSessionContext must be used within a SessionList/SignInSessionListItem'); + } + + return ctx; +} + +export function useSignInChooseSessionIsActive() { + const routerRef = SignInRouterCtx.useActorRef(); + return useActiveTags(routerRef, 'step:choose-session'); +} + +export function useSignInActiveSessionList() { + return SignInRouterCtx.useSelector( + state => + state.context.clerk?.client?.activeSessions?.map(s => ({ + id: s.id, + ...s.publicUserData, + })) || [], + ); +} diff --git a/packages/elements/src/react/sign-in/choose-session.tsx b/packages/elements/src/react/sign-in/choose-session/choose-session.tsx similarity index 69% rename from packages/elements/src/react/sign-in/choose-session.tsx rename to packages/elements/src/react/sign-in/choose-session/choose-session.tsx index b89bc80fbbe..04103c68956 100644 --- a/packages/elements/src/react/sign-in/choose-session.tsx +++ b/packages/elements/src/react/sign-in/choose-session/choose-session.tsx @@ -1,13 +1,18 @@ import { Slot } from '@radix-ui/react-slot'; import * as React from 'react'; -import { useActiveTags } from '../hooks'; -import { createContextForDomValidation } from '../utils/create-context-for-dom-validation'; -import { isValidComponentType } from '../utils/is-valid-component-type'; -import { SignInRouterCtx } from './context'; +import { createContextForDomValidation } from '~/react/utils/create-context-for-dom-validation'; +import { isValidComponentType } from '~/react/utils/is-valid-component-type'; + +import { + SignInActiveSessionContext, + useSignInActiveSessionContext, + useSignInActiveSessionList, + useSignInChooseSessionIsActive, +} from './choose-session.hooks'; // ----------------------------------- TYPES ------------------------------------ -// + export type SignInChooseSessionProps = React.HTMLAttributes; export type SignInSessionListProps = React.HTMLAttributes & { asChild?: boolean }; export type SignInSessionListItemProps = Omit, 'children'> & { @@ -18,34 +23,11 @@ export type SignInSessionListItemProps = Omit(null); - -// ----------------------------------- HOOKS ------------------------------------ - -function useSignInActiveSessionContext() { - const ctx = React.useContext(SignInActiveSessionContext); - - if (!ctx) { - throw new Error('SignInActiveSessionContext must be used within a SessionList/SignInSessionListItem'); - } - - return ctx; -} - -function useSignInActiveSessionList() { - return SignInRouterCtx.useSelector(state => - state.context.clerk.client.activeSessions.map(s => ({ - id: s.id, - ...s.publicUserData, - })), - ); -} // --------------------------------- COMPONENTS --------------------------------- export function SignInChooseSession({ children, ...props }: SignInChooseSessionProps) { - const routerRef = SignInRouterCtx.useActorRef(); - const activeState = useActiveTags(routerRef, 'step:choose-session'); + const activeState = useSignInChooseSessionIsActive(); return activeState ? ( @@ -65,7 +47,7 @@ export function SignInSessionList({ asChild, children, ...props }: SignInSession return React.Children.only(null); } - if (asChild && !isValidComponentType(children, SignInSessionListItem)) { + if (asChild && isValidComponentType(children, SignInSessionListItem)) { // TODO: Update error message throw new Error('asChild cannot be used with SessionListItem as the direct child'); } diff --git a/packages/elements/src/react/sign-in/choose-session/index.tsx b/packages/elements/src/react/sign-in/choose-session/index.tsx new file mode 100644 index 00000000000..edf1542238a --- /dev/null +++ b/packages/elements/src/react/sign-in/choose-session/index.tsx @@ -0,0 +1,2 @@ +export { SignInChooseSession, SignInSessionList, SignInSessionListItem } from './choose-session'; +export type { SignInSessionListProps, SignInChooseSessionProps, SignInSessionListItemProps } from './choose-session'; From df25a8f8f19eefa97bb9956dbe4f8d5fde3b8484 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 16 Aug 2024 11:09:57 -0400 Subject: [PATCH 03/10] feat(elements): Switch sessions --- .../elements/examples/nextjs/app/page.tsx | 15 +++++++++ .../app/sign-in/[[...sign-in]]/page.tsx | 8 ++++- .../app/sign-up/[[...sign-up]]/page.tsx | 5 +++ .../src/react/sign-in/action/action.tsx | 17 ++++------ .../src/react/sign-in/action/navigate.tsx | 8 ++--- .../sign-in/action/set-active-session.tsx | 7 ++-- .../choose-session/choose-session.hooks.ts | 32 +++++++++++++------ .../sign-in/choose-session/choose-session.tsx | 17 ++++++---- 8 files changed, 76 insertions(+), 33 deletions(-) diff --git a/packages/elements/examples/nextjs/app/page.tsx b/packages/elements/examples/nextjs/app/page.tsx index 11ffd63508e..2b5387cfe5d 100644 --- a/packages/elements/examples/nextjs/app/page.tsx +++ b/packages/elements/examples/nextjs/app/page.tsx @@ -84,6 +84,21 @@ export default function Home() {

    Modal Playground

    + + +

    + Sessions{' '} + + -> + +

    +

    Choose from Active Sessions via Multi-session support

    + +
    +
    - {({ session }) =>

    {session.id}

    }
    + + {({ session }) => ( +

    + {session.identifier} | Switch...{' '} +

    + )} +
    diff --git a/packages/elements/examples/nextjs/app/sign-up/[[...sign-up]]/page.tsx b/packages/elements/examples/nextjs/app/sign-up/[[...sign-up]]/page.tsx index da1cd079a6d..e163a4a3a73 100644 --- a/packages/elements/examples/nextjs/app/sign-up/[[...sign-up]]/page.tsx +++ b/packages/elements/examples/nextjs/app/sign-up/[[...sign-up]]/page.tsx @@ -187,6 +187,11 @@ export default function SignUpPage() { name='password' /> + + Sign Up diff --git a/packages/elements/src/react/sign-in/action/action.tsx b/packages/elements/src/react/sign-in/action/action.tsx index e260dbf9fe4..553e0ef1871 100644 --- a/packages/elements/src/react/sign-in/action/action.tsx +++ b/packages/elements/src/react/sign-in/action/action.tsx @@ -3,10 +3,11 @@ 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 & ( @@ -21,13 +22,7 @@ export type SignInActionProps = { asChild?: boolean } & FormSubmitProps & | ({ navigate?: never; resend: true; setActiveSession?: never; submit?: never } & SignInResendProps) ); -export type SignInActionCompProps = React.ForwardRefExoticComponent< - Exclude & { - to: SignInNavigateElementKey; - } & React.RefAttributes ->; - -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. @@ -47,7 +42,7 @@ const SIGN_IN_ACTION_NAME = 'SignInAction'; * Resend */ export const SignInAction = React.forwardRef, SignInActionProps>((props, forwardedRef) => { - const { submit, navigate, resend, ...rest } = props; + const { submit, navigate, resend, setActiveSession, ...rest } = props; let Comp: React.ForwardRefExoticComponent | undefined; if (submit) { @@ -56,6 +51,8 @@ export const SignInAction = React.forwardRef, SignInA Comp = SignInNavigate; } else if (resend) { Comp = SignInResend; + } else if (setActiveSession) { + Comp = SignInSetActiveSession; } return Comp ? ( @@ -67,4 +64,4 @@ export const SignInAction = React.forwardRef, SignInA ) : null; }); -SignInAction.displayName = SIGN_IN_ACTION_NAME; +SignInAction.displayName = DISPLAY_NAME; diff --git a/packages/elements/src/react/sign-in/action/navigate.tsx b/packages/elements/src/react/sign-in/action/navigate.tsx index 40c6d45ce8d..71f26662649 100644 --- a/packages/elements/src/react/sign-in/action/navigate.tsx +++ b/packages/elements/src/react/sign-in/action/navigate.tsx @@ -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; diff --git a/packages/elements/src/react/sign-in/action/set-active-session.tsx b/packages/elements/src/react/sign-in/action/set-active-session.tsx index e5cc2b20892..657b640ca28 100644 --- a/packages/elements/src/react/sign-in/action/set-active-session.tsx +++ b/packages/elements/src/react/sign-in/action/set-active-session.tsx @@ -4,6 +4,8 @@ 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'>; @@ -25,12 +27,13 @@ export type SignInSetActiveSessionProps = { export const SignInSetActiveSession = React.forwardRef( ({ 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: 'abc123' }; + const event: SignInRouterSessionSetActiveEvent = { type: 'SESSION.SET_ACTIVE', id: session.id }; if (actorRef.getSnapshot().can(event)) { actorRef.send(event); @@ -39,7 +42,7 @@ export const SignInSetActiveSession = React.forwardRef is an invalid event. You can only choose an active session from .`, ); } - }, [actorRef]); + }, [actorRef, session.id]); return ( (null); +export type SignInActiveSessionListItem = Pick & PublicUserData; -export function useSignInActiveSessionContext() { +export const SignInActiveSessionContext = React.createContext(null); + +export function useSignInActiveSessionContext(): SignInActiveSessionListItem { const ctx = React.useContext(SignInActiveSessionContext); if (!ctx) { @@ -20,12 +23,21 @@ export function useSignInChooseSessionIsActive() { return useActiveTags(routerRef, 'step:choose-session'); } -export function useSignInActiveSessionList() { - return SignInRouterCtx.useSelector( - state => - state.context.clerk?.client?.activeSessions?.map(s => ({ - id: s.id, - ...s.publicUserData, - })) || [], - ); +export type UseSignInActiveSessionListParams = { + omitCurrent: boolean; +}; + +export function useSignInActiveSessionList(params?: UseSignInActiveSessionListParams): SignInActiveSessionListItem[] { + const { omitCurrent = true } = params || {}; + + return SignInRouterCtx.useSelector(state => { + const activeSessions = state.context.clerk?.client?.activeSessions || []; + const currentSessionId = state.context.clerk?.session?.id; + const filteredSessions = omitCurrent ? activeSessions.filter(s => s.id !== currentSessionId) : activeSessions; + + return filteredSessions.map(s => ({ + id: s.id, + ...s.publicUserData, + })); + }); } diff --git a/packages/elements/src/react/sign-in/choose-session/choose-session.tsx b/packages/elements/src/react/sign-in/choose-session/choose-session.tsx index 04103c68956..f65dde9bb5d 100644 --- a/packages/elements/src/react/sign-in/choose-session/choose-session.tsx +++ b/packages/elements/src/react/sign-in/choose-session/choose-session.tsx @@ -6,6 +6,7 @@ import { isValidComponentType } from '~/react/utils/is-valid-component-type'; import { SignInActiveSessionContext, + type SignInActiveSessionListItem, useSignInActiveSessionContext, useSignInActiveSessionList, useSignInChooseSessionIsActive, @@ -14,10 +15,13 @@ import { // ----------------------------------- TYPES ------------------------------------ export type SignInChooseSessionProps = React.HTMLAttributes; -export type SignInSessionListProps = React.HTMLAttributes & { asChild?: boolean }; +export type SignInSessionListProps = React.HTMLAttributes & { + asChild?: boolean; + includeCurrentSession?: true; +}; export type SignInSessionListItemProps = Omit, 'children'> & { asChild?: boolean; - children: (session: any) => React.ReactNode; + children: ({ session }: { session: SignInActiveSessionListItem }) => React.ReactNode; }; // ---------------------------------- CONTEXT ----------------------------------- @@ -36,8 +40,8 @@ export function SignInChooseSession({ children, ...props }: SignInChooseSessionP ) : null; } -export function SignInSessionList({ asChild, children, ...props }: SignInSessionListProps) { - const sessions = useSignInActiveSessionList(); +export function SignInSessionList({ asChild, children, includeCurrentSession, ...props }: SignInSessionListProps) { + const sessions = useSignInActiveSessionList({ omitCurrent: !includeCurrentSession }); if (!children || !sessions?.length) { return null; @@ -76,9 +80,10 @@ export function SignInSessionList({ asChild, children, ...props }: SignInSession return
      {childrenWithCtx}
    ; } -export function SignInSessionListItem({ asChild, children, ...props }: SignInSessionListItemProps) { +export function SignInSessionListItem(props: SignInSessionListItemProps) { + const { asChild = false, children, ...passthroughProps } = props; const session = useSignInActiveSessionContext(); const Comp = asChild ? Slot : 'li'; - return {children({ session })}; + return {children({ session })}; } From 608d43d3e01ec6e81c6970ea7e8f6e6f96e8d260 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 16 Aug 2024 11:13:10 -0400 Subject: [PATCH 04/10] chore(elements): Add changeset --- .changeset/fifty-terms-switch.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/fifty-terms-switch.md diff --git a/.changeset/fifty-terms-switch.md b/.changeset/fifty-terms-switch.md new file mode 100644 index 00000000000..d206df4412b --- /dev/null +++ b/.changeset/fifty-terms-switch.md @@ -0,0 +1,18 @@ +--- +"@clerk/elements": minor +--- + + +Introduce multi-session choose account step and associated actions/components. + +Example: + +```tsx + + + + {({ session }) => <>{session.identifier} | Switch...} + + + +``` From fc7bd763f04da8f85c1464937b4e9011e05a5ad5 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Fri, 16 Aug 2024 11:17:05 -0400 Subject: [PATCH 05/10] chore(elements): Update changelog example --- .changeset/fifty-terms-switch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fifty-terms-switch.md b/.changeset/fifty-terms-switch.md index d206df4412b..1555151075f 100644 --- a/.changeset/fifty-terms-switch.md +++ b/.changeset/fifty-terms-switch.md @@ -9,7 +9,7 @@ Example: ```tsx - + {({ session }) => <>{session.identifier} | Switch...} From 2f7d1daf62e4f0e8e7f4bddae3e70ba173f0f522 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 16 Aug 2024 11:48:46 -0400 Subject: [PATCH 06/10] hook up sessionlist ui --- .../ui/src/components/sign-in/sign-in.tsx | 3 + .../sign-in/steps/choose-session.tsx | 202 +++++++++--------- 2 files changed, 109 insertions(+), 96 deletions(-) diff --git a/packages/ui/src/components/sign-in/sign-in.tsx b/packages/ui/src/components/sign-in/sign-in.tsx index 6c74d75637a..e3c175ab4e6 100644 --- a/packages/ui/src/components/sign-in/sign-in.tsx +++ b/packages/ui/src/components/sign-in/sign-in.tsx @@ -9,6 +9,8 @@ import { SignInResetPassword } from '~/components/sign-in/steps/reset-password'; import { SignInStart } from '~/components/sign-in/steps/start'; import { SignInVerifications } from '~/components/sign-in/steps/verifications'; +import { SignInChooseSession } from './steps/choose-session'; + /** * Implementation Details: * @@ -35,6 +37,7 @@ export function SignIn() { + )} diff --git a/packages/ui/src/components/sign-in/steps/choose-session.tsx b/packages/ui/src/components/sign-in/steps/choose-session.tsx index d75f51b2aec..62cc3bd421f 100644 --- a/packages/ui/src/components/sign-in/steps/choose-session.tsx +++ b/packages/ui/src/components/sign-in/steps/choose-session.tsx @@ -1,8 +1,8 @@ -import { useClerk } from '@clerk/clerk-react'; +import * as SignIn from '@clerk/elements/sign-in'; import { cva } from 'cva'; import { Button } from 'react-aria-components'; -import { LOCALIZATION_NEEDED } from '~/constants/localizations'; +import { LOCALIZATION_NEEDED } from '~/constants/localizations'; import { useAppearance } from '~/hooks/use-appearance'; import { useDevModeWarning } from '~/hooks/use-dev-mode-warning'; import { useDisplayConfig } from '~/hooks/use-display-config'; @@ -57,7 +57,6 @@ const sessionAction = cva({ }); export function SignInChooseSession() { - const clerk = useClerk(); const { t } = useLocalizations(); const { layout } = useAppearance(); const isDev = useDevModeWarning(); @@ -70,107 +69,118 @@ export function SignInChooseSession() { termsPageUrl: layout?.termsPageUrl, }; - const activeSessions = clerk.client.activeSessions; - return ( - - - - {t('signIn.accountSwitcher.title')} - {t('signIn.accountSwitcher.subtitle')} - - -
      - {activeSessions?.map(session => { - const { userId, identifier, firstName, lastName, hasImage, imageUrl } = session.publicUserData; - const { title, subtitle } = getTitleAndSubtitle({ firstName, lastName, identifier }); - return ( -
    • + + + + {t('signIn.accountSwitcher.title')} + {t('signIn.accountSwitcher.subtitle')} + + +
      + + + {({ session }) => { + const { identifier, firstName, lastName, hasImage, imageUrl } = session; + const { title, subtitle } = getTitleAndSubtitle({ firstName, lastName, identifier }); + return ( +
    • + + + +
    • + ); + }} + + +
      + - - ); - })} -
    • - -
    • -
    -
    -
    - - - - - -
    +
    + + + + + + + + + + +
    ); } From 8079afca1c061d27f30b25ebb219f544db1447f1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 16 Aug 2024 12:01:35 -0400 Subject: [PATCH 07/10] add changeset --- .changeset/gold-cameras-impress.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/gold-cameras-impress.md diff --git a/.changeset/gold-cameras-impress.md b/.changeset/gold-cameras-impress.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/gold-cameras-impress.md @@ -0,0 +1,2 @@ +--- +--- From 0d0aa173c48b275419e566011b5f07b4349f31aa Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 16 Aug 2024 12:43:53 -0400 Subject: [PATCH 08/10] add signout functionality --- .../ui/src/components/sign-in/steps/choose-session.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/sign-in/steps/choose-session.tsx b/packages/ui/src/components/sign-in/steps/choose-session.tsx index 62cc3bd421f..4fe19af7f7a 100644 --- a/packages/ui/src/components/sign-in/steps/choose-session.tsx +++ b/packages/ui/src/components/sign-in/steps/choose-session.tsx @@ -1,3 +1,4 @@ +import { useClerk } from '@clerk/clerk-react'; import * as SignIn from '@clerk/elements/sign-in'; import { cva } from 'cva'; import { Button } from 'react-aria-components'; @@ -57,6 +58,7 @@ const sessionAction = cva({ }); export function SignInChooseSession() { + const { signOut } = useClerk(); const { t } = useLocalizations(); const { layout } = useAppearance(); const isDev = useDevModeWarning(); @@ -160,7 +162,12 @@ export function SignInChooseSession() { - From bf497d3b3f38f9638396a2315a0941c1edc811b5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 16 Aug 2024 15:08:37 -0400 Subject: [PATCH 10/10] fix first last name join --- packages/ui/src/components/sign-in/steps/choose-session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/sign-in/steps/choose-session.tsx b/packages/ui/src/components/sign-in/steps/choose-session.tsx index 2968a6e96d7..81e9a4c535e 100644 --- a/packages/ui/src/components/sign-in/steps/choose-session.tsx +++ b/packages/ui/src/components/sign-in/steps/choose-session.tsx @@ -45,7 +45,7 @@ function getTitleAndSubtitle({ let title = ''; let subtitle = ''; if (firstName || lastName) { - title = `${firstName} ${lastName}`; + title = [firstName, lastName].filter(Boolean).join(' '); subtitle = identifier || ''; } else { title = identifier || '';