From 93b49142f0d4168540343043e2ffec8d678f29e4 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 29 May 2024 12:01:06 -0400 Subject: [PATCH 1/6] feat(elements): Initial virtual router --- .../examples/nextjs/app/modal/page.tsx | 420 ++++++++++++++++++ .../elements/examples/nextjs/app/page.tsx | 13 + .../elements/examples/nextjs/package.json | 1 + .../elements/src/internals/constants/index.ts | 6 +- .../machines/sign-in/router.machine.ts | 4 +- .../machines/sign-up/router.machine.ts | 4 +- .../third-party/third-party.machine.ts | 6 +- .../internals/machines/types/router.types.ts | 2 + packages/elements/src/react/common/index.ts | 2 + packages/elements/src/react/router/next.ts | 2 + packages/elements/src/react/router/react.tsx | 4 +- packages/elements/src/react/router/router.ts | 21 +- packages/elements/src/react/router/virtual.ts | 79 ++++ packages/elements/src/react/sign-in/root.tsx | 35 +- packages/elements/src/react/sign-up/root.tsx | 14 +- 15 files changed, 584 insertions(+), 29 deletions(-) create mode 100644 packages/elements/examples/nextjs/app/modal/page.tsx create mode 100644 packages/elements/src/react/router/virtual.ts diff --git a/packages/elements/examples/nextjs/app/modal/page.tsx b/packages/elements/examples/nextjs/app/modal/page.tsx new file mode 100644 index 00000000000..6cc5759510c --- /dev/null +++ b/packages/elements/examples/nextjs/app/modal/page.tsx @@ -0,0 +1,420 @@ +'use client'; + +import * as Clerk from '@clerk/elements/common'; +import * as SignIn from '@clerk/elements/sign-in'; +import { SignedIn, SignOutButton } from '@clerk/nextjs'; +import * as Popover from '@radix-ui/react-popover'; +import Link from 'next/link'; +import { type ComponentProps, useState } from 'react'; + +import { H1, H3, P } from '@/components/design'; +import { CustomField } from '@/components/form'; +import { Spinner } from '@/components/spinner'; + +function CustomProvider({ + children, + provider, +}: { + children: string; + provider: ComponentProps['name']; +}) { + return ( + + {isLoading => ( + + + + {isLoading ? ( + <> + Loading... + + ) : ( + children + )} + + + )} + + ); +} + +function TextButton({ children, ...props }: ComponentProps<'button'>) { + return ( + + ); +} + +function Button({ children, ...props }: ComponentProps<'button'>) { + return ( + + ); +} + +function CustomSubmit({ children }: { children: string }) { + return ( + + {isLoading => (isLoading ? : children)} + + ); +} + +function ResendableFallback({ resendableAfter }: { resendableAfter: number }) { + return

Didn't recieve a code? Retry in {resendableAfter} seconds.

; +} + +function CustomResendable() { + return ( + + Didn't recieve a code? Retry Now + + ); +} + +export default function SignInPage() { + const router = Clerk.useVirtualRouter(); + const [continueWithEmail, setContinueWithEmail] = useState(false); + + return ( +
+ + +

+ Sign Out +

+
+
+ + + Open Sign In + + + +
+ +
+

Sign In

+

+ Don't have an account?{' '} + + Sign Up + +

+
+
+ + +
+ Continue with GitHub + Continue with Google +
+ + {continueWithEmail ? ( + <> + + {fieldState => ( + <> + Email + + + + )} + + + Sign in with Email + + ) : ( + setContinueWithEmail(true)}>Continue with Email + )} +
+
+ } + > +
+
+

Sign In

+

+ Don't have an account?{' '} + + Sign Up + +

+
+ +
+ + {isLoading => Loading: {JSON.stringify(isLoading, null, 2)}} + +
+ + +
+ + +
+ Continue with GitHub + Continue with Google +
+ + {continueWithEmail ? ( + <> + + {fieldState => ( + <> + Email + + + + )} + + + Sign in with Email + + ) : ( + setContinueWithEmail(true)}>Continue with Email + )} +
+
+ + +

CHOOSE STRATEGY:

+ + Continue with GitHub + Continue with Google + + + + + + + + + + + + + + + Go back + +
+ + +

FORGOT PASSWORD:

+ + + + + + + + + +

Or

+ + Continue with GitHub + Continue with Google + + + Go back + +
+ + +
+ + + +

+ Welcome back ! +

+ + + + Verify + + + Forgot Password + +
+ + +

+ Welcome back! We've sent a temporary code to +

+ + + + + + Verify +
+ + +

+ Welcome back! We've sent a temporary code to +

+ + + + + + Verify +
+ + +

Verify your email

+ +

+ We've sent a verification code to +

+ + + + Continue +
+ + +

Verify your phone number

+ +

+ We've sent a verification code to +

+ + + + Continue +
+
+ + + Use another method + +
+ + +
+

Reset your password

+ +

Please reset your password to continue:

+ + + Update Password +
+
+
+ + +
+ + + + + ); +} diff --git a/packages/elements/examples/nextjs/app/page.tsx b/packages/elements/examples/nextjs/app/page.tsx index f4c60493397..b73636f99ae 100644 --- a/packages/elements/examples/nextjs/app/page.tsx +++ b/packages/elements/examples/nextjs/app/page.tsx @@ -71,6 +71,19 @@ export default function Home() {

OTP Playground

+ +

+ Modal{' '} + + -> + +

+

Modal Playground

+ + ({ + actions: sendTo(ThirdPartyMachineId, ({ context, event }) => ({ type: 'REDIRECT', params: { strategy: event.strategy, + redirectUrl: context.redirectUrl, }, })), }, @@ -196,6 +197,7 @@ export const SignInRouterMachine = setup({ clerk: event.clerk, exampleMode: event.exampleMode || false, formRef: event.formRef, + redirectUrl: event.redirectUrl, loading: { isLoading: false, }, diff --git a/packages/elements/src/internals/machines/sign-up/router.machine.ts b/packages/elements/src/internals/machines/sign-up/router.machine.ts index 88845dd2168..ed4c3a9526b 100644 --- a/packages/elements/src/internals/machines/sign-up/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/router.machine.ts @@ -161,10 +161,11 @@ export const SignUpRouterMachine = setup({ initial: 'Idle', on: { 'AUTHENTICATE.OAUTH': { - actions: sendTo(ThirdPartyMachineId, ({ event }) => ({ + actions: sendTo(ThirdPartyMachineId, ({ context, event }) => ({ type: 'REDIRECT', params: { strategy: event.strategy, + redirectUrl: context.redirectUrl, }, })), }, @@ -193,6 +194,7 @@ export const SignUpRouterMachine = setup({ INIT: { actions: assign(({ event }) => ({ clerk: event.clerk, + redirectUrl: event.redirectUrl, router: event.router, signInPath: event.signInPath || SIGN_IN_DEFAULT_BASE_PATH, loading: { diff --git a/packages/elements/src/internals/machines/third-party/third-party.machine.ts b/packages/elements/src/internals/machines/third-party/third-party.machine.ts index bfb47396c2b..e2351b07ba5 100644 --- a/packages/elements/src/internals/machines/third-party/third-party.machine.ts +++ b/packages/elements/src/internals/machines/third-party/third-party.machine.ts @@ -80,9 +80,9 @@ export const ThirdPartyMachine = setup({ assertEvent(event, 'REDIRECT'); const clerk: LoadedClerk = context.parent.getSnapshot().context.clerk; - - const redirectUrl = - event.params.redirectUrl || clerk.buildUrlWithAuth(`${context.basePath}${SSO_CALLBACK_PATH_ROUTE}`); + const redirectUrl = clerk.buildUrlWithAuth( + event.params.redirectUrl ?? `${context.basePath}${SSO_CALLBACK_PATH_ROUTE}`, + ); const redirectUrlComplete = event.params.redirectUrlComplete || redirectUrl; return { diff --git a/packages/elements/src/internals/machines/types/router.types.ts b/packages/elements/src/internals/machines/types/router.types.ts index 904fb56dd4e..4d764a71879 100644 --- a/packages/elements/src/internals/machines/types/router.types.ts +++ b/packages/elements/src/internals/machines/types/router.types.ts @@ -45,6 +45,7 @@ export type BaseRouterRedirectEvent = export interface BaseRouterInput { clerk: LoadedClerk; + redirectUrl?: string; router?: ClerkRouter; exampleMode?: boolean; } @@ -54,6 +55,7 @@ export interface BaseRouterInput { export interface BaseRouterContext { clerk: LoadedClerk; error?: ClerkElementsError; + redirectUrl?: string; router?: ClerkRouter; exampleMode?: boolean; } diff --git a/packages/elements/src/react/common/index.ts b/packages/elements/src/react/common/index.ts index 72504ef7901..da5a1d0880b 100644 --- a/packages/elements/src/react/common/index.ts +++ b/packages/elements/src/react/common/index.ts @@ -5,6 +5,8 @@ export { Field, FieldError, FieldState, GlobalError, Input, Label, Submit } from export { Connection, Icon } from '~/react/common/connections'; export { Loading } from '~/react/common/loading'; +export { useVirtualRouter } from '~/react/router/virtual'; + export type { FormFieldErrorProps, FormErrorProps, diff --git a/packages/elements/src/react/router/next.ts b/packages/elements/src/react/router/next.ts index 97a00fa9508..71f7a6a8b12 100644 --- a/packages/elements/src/react/router/next.ts +++ b/packages/elements/src/react/router/next.ts @@ -19,6 +19,8 @@ export const useNextRouter = (): ClerkHostRouter => { typeof window !== 'undefined' && window.next && window.next.version >= NEXT_WINDOW_HISTORY_SUPPORT_VERSION; return { + mode: 'path', + name: 'NextRouter', push: (path: string) => router.push(path), replace: (path: string) => canUseWindowHistoryAPIs ? window.history.replaceState(null, '', path) : router.replace(path), diff --git a/packages/elements/src/react/router/react.tsx b/packages/elements/src/react/router/react.tsx index f436d25d2e4..79af937b554 100644 --- a/packages/elements/src/react/router/react.tsx +++ b/packages/elements/src/react/router/react.tsx @@ -16,13 +16,13 @@ export function useClerkRouter() { } export function Router({ + basePath, children, router, - basePath, }: { - router: ClerkHostRouter; children: React.ReactNode; basePath?: string; + router: ClerkHostRouter; }) { const clerkRouter = createClerkRouter(router, basePath); diff --git a/packages/elements/src/react/router/router.ts b/packages/elements/src/react/router/router.ts index 2310976c178..d4ed6b74841 100644 --- a/packages/elements/src/react/router/router.ts +++ b/packages/elements/src/react/router/router.ts @@ -6,11 +6,13 @@ export const PRESERVED_QUERYSTRING_PARAMS = ['after_sign_in_url', 'after_sign_up * This type represents a generic router interface that Clerk relies on to interact with the host router. */ export type ClerkHostRouter = { + readonly mode: 'path' | 'virtual'; + readonly name: string; + pathname: () => string; push: (path: string) => void; replace: (path: string) => void; - shallowPush: (path: string) => void; - pathname: () => string; searchParams: () => URLSearchParams; + shallowPush: (path: string) => void; }; /** @@ -29,10 +31,21 @@ export type ClerkRouter = { * Matches the provided path against the router's current path. If index is provided, matches against the root route of the router. */ match: (path?: string, index?: boolean) => boolean; + + /** + * Mode of the router instance, path-based or virtual + */ + mode: 'path' | 'virtual'; + + /** + * Name of the router instance + */ + readonly name: string; + /** * Navigates to the provided path via a history push */ - push: ClerkHostRouter['push']; + readonly push: ClerkHostRouter['push']; /** * Navigates to the provided path via a history replace */ @@ -119,6 +132,8 @@ export function createClerkRouter(router: ClerkHostRouter, basePath: string = '/ return { child, match, + mode: router.mode, + name: router.name, push, replace, shallowPush, diff --git a/packages/elements/src/react/router/virtual.ts b/packages/elements/src/react/router/virtual.ts new file mode 100644 index 00000000000..88218fa3f28 --- /dev/null +++ b/packages/elements/src/react/router/virtual.ts @@ -0,0 +1,79 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; + +import type { ClerkHostRouter } from './router'; + +const DUMMY_ORIGIN = 'https://clerk.dummy'; + +// TODO: introduce history stack? +class VirtualRouter implements ClerkHostRouter { + readonly name = 'VirtualRouter'; + readonly mode = 'virtual'; + + #url: URL; + #listeners: Set<(url: URL) => void> = new Set(); + + constructor(path?: string) { + const origin = typeof window === 'undefined' ? DUMMY_ORIGIN : window.location.origin; + + this.#url = new URL(path ?? '/', origin); + } + + push(path: string) { + const newUrl = new URL(this.#url.toString()); + newUrl.pathname = path; + + this.#url = newUrl; + this.emit(); + } + + replace(path: string) { + this.push(path); + } + + shallowPush(path: string) { + this.push(path); + } + + pathname() { + return this.#url.pathname; + } + + searchParams() { + return this.#url.searchParams; + } + + subscribe(listener: () => void) { + this.#listeners.add(listener); + + return () => this.#listeners.delete(listener); + } + + emit() { + this.#listeners.forEach(listener => listener(this.#url)); + } + + getSnapshot() { + return this.#url; + } +} + +const virtualRouter = new VirtualRouter('/'); + +export const useVirtualRouter = (): ClerkHostRouter => { + const url = useSyncExternalStore( + virtualRouter.subscribe.bind(virtualRouter), + virtualRouter.getSnapshot.bind(virtualRouter), + ); + + return { + mode: virtualRouter.mode, + name: virtualRouter.name, + pathname: () => url.pathname, + push: virtualRouter.push.bind(virtualRouter), + replace: virtualRouter.replace.bind(virtualRouter), + searchParams: () => url.searchParams, + shallowPush: virtualRouter.shallowPush.bind(virtualRouter), + }; +}; diff --git a/packages/elements/src/react/sign-in/root.tsx b/packages/elements/src/react/sign-in/root.tsx index ad0dc420cc0..37b07b965b9 100644 --- a/packages/elements/src/react/sign-in/root.tsx +++ b/packages/elements/src/react/sign-in/root.tsx @@ -1,5 +1,6 @@ import { ClerkLoaded, ClerkLoading, useClerk } from '@clerk/clerk-react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; +import type { SignInProps } from '@clerk/types'; import React, { useEffect } from 'react'; import { createActor } from 'xstate'; @@ -8,20 +9,24 @@ import { FormStoreProvider, useFormStore } from '~/internals/machines/form/form. import type { SignInRouterInitEvent } from '~/internals/machines/sign-in'; import { SignInRouterMachine } from '~/internals/machines/sign-in'; import { inspect } from '~/internals/utils/inspector'; +import type { ClerkHostRouter } from '~/react/router'; import { Router, useClerkRouter, useNextRouter } from '~/react/router'; import { SignInRouterCtx } from '~/react/sign-in/context'; import { Form } from '../common/form'; -type SignInFlowProviderProps = { +type SignInDefaultProps = Pick; + +type SignInFlowProviderProps = SignInDefaultProps & { children: React.ReactNode; exampleMode?: boolean; + redirectUrl?: string; }; const actor = createActor(SignInRouterMachine, { inspect }); actor.start(); -function SignInFlowProvider({ children, exampleMode }: SignInFlowProviderProps) { +function SignInFlowProvider({ children, exampleMode, redirectUrl }: SignInFlowProviderProps) { const clerk = useClerk(); const router = useClerkRouter(); const formRef = useFormStore(); @@ -36,6 +41,7 @@ function SignInFlowProvider({ children, exampleMode }: SignInFlowProviderProps) clerk, exampleMode, formRef, + redirectUrl, router, signUpPath: SIGN_UP_DEFAULT_BASE_PATH, }; @@ -51,16 +57,15 @@ function SignInFlowProvider({ children, exampleMode }: SignInFlowProviderProps) return {children}; } -export type SignInRootProps = { +export type SignInRootProps = SignInFlowProviderProps & { + fallback?: React.ReactNode; /** * The base path for your sign-in route. Defaults to `/sign-in`. * * TODO: re-use usePathnameWithoutCatchAll from the next SDK */ path?: string; - children: React.ReactNode; - fallback?: React.ReactNode; - exampleMode?: boolean; + router?: ClerkHostRouter; }; /** @@ -80,22 +85,25 @@ export type SignInRootProps = { */ export function SignInRoot({ children, - path = SIGN_IN_DEFAULT_BASE_PATH, - fallback = null, exampleMode, + fallback = null, + path = SIGN_IN_DEFAULT_BASE_PATH, + redirectUrl, + router: routerFromProps, }: SignInRootProps): JSX.Element | null { const clerk = useClerk(); + const nextRouter = useNextRouter(); clerk.telemetry?.record( eventComponentMounted('Elements_SignInRoot', { - path, - fallback: Boolean(fallback), exampleMode: Boolean(exampleMode), + fallback: Boolean(fallback), + path, }), ); // TODO: eventually we'll rely on the framework SDK to specify its host router, but for now we'll default to Next.js - const router = useNextRouter(); + const router = routerFromProps ?? nextRouter; const isRootPath = path === router.pathname(); return ( @@ -104,7 +112,10 @@ export function SignInRoot({ router={router} > - + {isRootPath ? (
{fallback}
diff --git a/packages/elements/src/react/sign-up/root.tsx b/packages/elements/src/react/sign-up/root.tsx index 0eea9bf0dfd..c555cfa7b28 100644 --- a/packages/elements/src/react/sign-up/root.tsx +++ b/packages/elements/src/react/sign-up/root.tsx @@ -9,6 +9,7 @@ import { FormStoreProvider, useFormStore } from '~/internals/machines/form/form. import type { SignUpRouterInitEvent } from '~/internals/machines/sign-up'; import { SignUpRouterMachine } from '~/internals/machines/sign-up'; import { inspect } from '~/internals/utils/inspector'; +import type { ClerkHostRouter } from '~/react/router'; import { Router, useClerkRouter, useNextRouter } from '~/react/router'; import { SignUpRouterCtx } from '~/react/sign-up/context'; @@ -53,10 +54,11 @@ function SignUpFlowProvider({ children, exampleMode }: SignUpFlowProviderProps) } export type SignUpRootProps = { - path?: string; children: React.ReactNode; - fallback?: React.ReactNode; exampleMode?: boolean; + fallback?: React.ReactNode; + path?: string; + router?: ClerkHostRouter; }; /** @@ -76,11 +78,13 @@ export type SignUpRootProps = { */ export function SignUpRoot({ children, - path = SIGN_UP_DEFAULT_BASE_PATH, - fallback = null, exampleMode, + fallback = null, + path = SIGN_UP_DEFAULT_BASE_PATH, + router: routerFromProps, }: SignUpRootProps): JSX.Element | null { const clerk = useClerk(); + const nextRouter = useNextRouter(); clerk.telemetry?.record( eventComponentMounted('Elements_SignUpRoot', { @@ -91,7 +95,7 @@ export function SignUpRoot({ ); // TODO: eventually we'll rely on the framework SDK to specify its host router, but for now we'll default to Next.js - const router = useNextRouter(); + const router = routerFromProps ?? nextRouter; const isRootPath = path === router.pathname(); return ( From ec69ef6e7a295c3ab6295837cc76ccf6d5a3e0e3 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 29 May 2024 13:14:04 -0400 Subject: [PATCH 2/6] chore(elements): Add changeset --- .changeset/lemon-crews-hammer.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/lemon-crews-hammer.md diff --git a/.changeset/lemon-crews-hammer.md b/.changeset/lemon-crews-hammer.md new file mode 100644 index 00000000000..b0de91e3ceb --- /dev/null +++ b/.changeset/lemon-crews-hammer.md @@ -0,0 +1,6 @@ +--- +'@clerk/elements': patch +--- + +- Adds virtual router to support modal scenarios +- Adds redirectUrl prop to `SignIn.Root` and `SignUp.Root` to handle callback redirect paths From 80c9ae7662685ab814cc98b113a4f87a398679d0 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 29 May 2024 13:54:05 -0400 Subject: [PATCH 3/6] fix(elements): mockRouter type --- packages/elements/src/react/router/__tests__/router.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/elements/src/react/router/__tests__/router.test.ts b/packages/elements/src/react/router/__tests__/router.test.ts index 834b8729a59..549cabf62e3 100644 --- a/packages/elements/src/react/router/__tests__/router.test.ts +++ b/packages/elements/src/react/router/__tests__/router.test.ts @@ -2,6 +2,8 @@ import { createClerkRouter } from '../router'; describe('createClerkRouter', () => { const mockRouter = { + name: 'mockRouter', + mode: 'path' as const, pathname: jest.fn(), searchParams: jest.fn(), push: jest.fn(), From ea53755b77f4a71126aa206c3ef0c0353531f850 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 29 May 2024 14:19:18 -0400 Subject: [PATCH 4/6] chore(elements): Update types and example --- .changeset/lemon-crews-hammer.md | 2 +- .../examples/nextjs/app/modal/page.tsx | 540 +++++++++--------- packages/elements/src/react/router/router.ts | 4 +- packages/elements/src/react/sign-in/root.tsx | 5 +- packages/elements/src/react/sign-up/root.tsx | 5 +- 5 files changed, 278 insertions(+), 278 deletions(-) diff --git a/.changeset/lemon-crews-hammer.md b/.changeset/lemon-crews-hammer.md index b0de91e3ceb..6cce76b34a5 100644 --- a/.changeset/lemon-crews-hammer.md +++ b/.changeset/lemon-crews-hammer.md @@ -3,4 +3,4 @@ --- - Adds virtual router to support modal scenarios -- Adds redirectUrl prop to `SignIn.Root` and `SignUp.Root` to handle callback redirect paths +- Adds `redirectUrl` prop to `SignIn.Root` and `SignUp.Root` to handle callback redirect paths diff --git a/packages/elements/examples/nextjs/app/modal/page.tsx b/packages/elements/examples/nextjs/app/modal/page.tsx index 6cc5759510c..20fc602b3b6 100644 --- a/packages/elements/examples/nextjs/app/modal/page.tsx +++ b/packages/elements/examples/nextjs/app/modal/page.tsx @@ -2,7 +2,7 @@ import * as Clerk from '@clerk/elements/common'; import * as SignIn from '@clerk/elements/sign-in'; -import { SignedIn, SignOutButton } from '@clerk/nextjs'; +import { SignedIn, SignedOut, SignOutButton } from '@clerk/nextjs'; import * as Popover from '@radix-ui/react-popover'; import Link from 'next/link'; import { type ComponentProps, useState } from 'react'; @@ -100,74 +100,29 @@ export default function SignInPage() { return (
- - -

- Sign Out -

-
-
- - Open Sign In +
+ + +

+ Sign Out +

+
+
+ + + + Open Sign In + + +
- -
- -
-

Sign In

-

- Don't have an account?{' '} - - Sign Up - -

-
-
- - -
- Continue with GitHub - Continue with Google -
- - {continueWithEmail ? ( - <> - - {fieldState => ( - <> - Email - - - - )} - - - Sign in with Email - - ) : ( - setContinueWithEmail(true)}>Continue with Email - )} -
-
- } - > + +

Sign In

@@ -181,237 +136,286 @@ export default function SignInPage() {

+
+ + +
+ Continue with GitHub + Continue with Google +
-
- - {isLoading => Loading: {JSON.stringify(isLoading, null, 2)}} - + {continueWithEmail ? ( + <> + + {fieldState => ( + <> + Email + + + + )} + + + Sign in with Email + + ) : ( + setContinueWithEmail(true)}>Continue with Email + )}
+
+ } + > +
+
+

Sign In

+

+ Don't have an account?{' '} + + Sign Up + +

+
+ +
+ + {isLoading => Loading: {JSON.stringify(isLoading, null, 2)}} + +
+ + +
+ - -
- - -
- Continue with GitHub - Continue with Google -
- - {continueWithEmail ? ( - <> - - {fieldState => ( - <> - Email - - - - )} - - - Sign in with Email - - ) : ( - setContinueWithEmail(true)}>Continue with Email - )} +
+ Continue with GitHub + Continue with Google
- - + + {fieldState => ( + <> + Email + + + + )} + + + Sign in with Email + + ) : ( + setContinueWithEmail(true)}>Continue with Email + )} +
+
+ + +

CHOOSE STRATEGY:

+ + Continue with GitHub + Continue with Google + + -

CHOOSE STRATEGY:

+ +
- Continue with GitHub - Continue with Google + + + - - - + + + - - - + + Go back + +
+ + +

FORGOT PASSWORD:

+ + + + - - - + + + - - Go back - -
+

Or

+ + Continue with GitHub + Continue with Google - -

FORGOT PASSWORD:

+ Go back + +
- - - + +
+ - - - + +

+ Welcome back ! +

-

Or

+ - Continue with GitHub - Continue with Google + Verify - - Go back - - + + Forgot Password + +
- -
- + +

+ Welcome back! We've sent a temporary code to +

- -

- Welcome back ! -

+ - + - Verify + Verify +
- - Forgot Password - -
- - -

- Welcome back! We've sent a temporary code to -

- - - - - - Verify -
- - -

- Welcome back! We've sent a temporary code to -

- - - - - - Verify -
- - -

Verify your email

- -

- We've sent a verification code to -

- - - - Continue -
- - -

Verify your phone number

- -

- We've sent a verification code to -

- - - - Continue -
-
+ +

+ Welcome back! We've sent a temporary code to +

- - Use another method - -
+ + + + + Verify + - -
-

Reset your password

+ +

Verify your email

-

Please reset your password to continue:

+

+ We've sent a verification code to +

- Update Password -
-
-
- - -
+ + Continue + + + +

Verify your phone number

+ +

+ We've sent a verification code to +

+ + + + Continue +
+
+ + + Use another method + + + + +
+

Reset your password

+ +

Please reset your password to continue:

+ + + Update Password +
+
+
+ + diff --git a/packages/elements/src/react/router/router.ts b/packages/elements/src/react/router/router.ts index d4ed6b74841..d2d7d88d14f 100644 --- a/packages/elements/src/react/router/router.ts +++ b/packages/elements/src/react/router/router.ts @@ -35,7 +35,7 @@ export type ClerkRouter = { /** * Mode of the router instance, path-based or virtual */ - mode: 'path' | 'virtual'; + readonly mode: 'path' | 'virtual'; /** * Name of the router instance @@ -45,7 +45,7 @@ export type ClerkRouter = { /** * Navigates to the provided path via a history push */ - readonly push: ClerkHostRouter['push']; + push: ClerkHostRouter['push']; /** * Navigates to the provided path via a history replace */ diff --git a/packages/elements/src/react/sign-in/root.tsx b/packages/elements/src/react/sign-in/root.tsx index 37b07b965b9..60923736c45 100644 --- a/packages/elements/src/react/sign-in/root.tsx +++ b/packages/elements/src/react/sign-in/root.tsx @@ -1,6 +1,5 @@ import { ClerkLoaded, ClerkLoading, useClerk } from '@clerk/clerk-react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; -import type { SignInProps } from '@clerk/types'; import React, { useEffect } from 'react'; import { createActor } from 'xstate'; @@ -15,9 +14,7 @@ import { SignInRouterCtx } from '~/react/sign-in/context'; import { Form } from '../common/form'; -type SignInDefaultProps = Pick; - -type SignInFlowProviderProps = SignInDefaultProps & { +type SignInFlowProviderProps = { children: React.ReactNode; exampleMode?: boolean; redirectUrl?: string; diff --git a/packages/elements/src/react/sign-up/root.tsx b/packages/elements/src/react/sign-up/root.tsx index c555cfa7b28..c937dfe4b32 100644 --- a/packages/elements/src/react/sign-up/root.tsx +++ b/packages/elements/src/react/sign-up/root.tsx @@ -18,6 +18,7 @@ import { Form } from '../common/form'; type SignUpFlowProviderProps = { children: React.ReactNode; exampleMode?: boolean; + redirectUrl?: string; }; const actor = createActor(SignUpRouterMachine, { inspect }); @@ -53,9 +54,7 @@ function SignUpFlowProvider({ children, exampleMode }: SignUpFlowProviderProps) return isReady ? {children} : null; } -export type SignUpRootProps = { - children: React.ReactNode; - exampleMode?: boolean; +export type SignUpRootProps = SignUpFlowProviderProps & { fallback?: React.ReactNode; path?: string; router?: ClerkHostRouter; From 741ca62a656ad49f139ffa77306ce975e0f8c059 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 29 May 2024 18:34:36 -0400 Subject: [PATCH 5/6] chore(elements): Update for AP SSO callbacks --- .changeset/lemon-crews-hammer.md | 5 +++-- .../examples/nextjs/app/modal/page.tsx | 4 +--- .../elements/src/internals/constants/index.ts | 7 ++++++ .../machines/sign-in/router.machine.ts | 9 ++++++-- .../machines/sign-up/router.machine.ts | 9 ++++++-- .../third-party/third-party.actors.ts | 10 +++------ .../third-party/third-party.machine.ts | 14 +----------- .../internals/machines/types/router.types.ts | 2 -- packages/elements/src/react/common/index.ts | 2 -- packages/elements/src/react/router/index.ts | 1 + packages/elements/src/react/router/router.ts | 6 +++-- packages/elements/src/react/sign-in/root.tsx | 22 ++++++------------- packages/elements/src/react/sign-up/root.tsx | 14 +++++------- 13 files changed, 47 insertions(+), 58 deletions(-) diff --git a/.changeset/lemon-crews-hammer.md b/.changeset/lemon-crews-hammer.md index 6cce76b34a5..c8c677ceced 100644 --- a/.changeset/lemon-crews-hammer.md +++ b/.changeset/lemon-crews-hammer.md @@ -1,6 +1,7 @@ --- -'@clerk/elements': patch +'@clerk/elements': minor --- - Adds virtual router to support modal scenarios -- Adds `redirectUrl` prop to `SignIn.Root` and `SignUp.Root` to handle callback redirect paths +- Adds `routing` prop to `SignIn.Root` and `SignUp.Root` for handling `virtual` routing +- Better support for Account Portal redirect callback flows diff --git a/packages/elements/examples/nextjs/app/modal/page.tsx b/packages/elements/examples/nextjs/app/modal/page.tsx index 20fc602b3b6..6f9c5b49c51 100644 --- a/packages/elements/examples/nextjs/app/modal/page.tsx +++ b/packages/elements/examples/nextjs/app/modal/page.tsx @@ -95,7 +95,6 @@ function CustomResendable() { } export default function SignInPage() { - const router = Clerk.useVirtualRouter(); const [continueWithEmail, setContinueWithEmail] = useState(false); return ( @@ -120,8 +119,7 @@ export default function SignInPage() {
diff --git a/packages/elements/src/internals/constants/index.ts b/packages/elements/src/internals/constants/index.ts index a5980805526..be3182b57b4 100644 --- a/packages/elements/src/internals/constants/index.ts +++ b/packages/elements/src/internals/constants/index.ts @@ -39,3 +39,10 @@ export const ERROR_CODES = { SAML_USER_ATTRIBUTE_MISSING: 'saml_user_attribute_missing', USER_LOCKED: 'user_locked', }; + +export const ROUTING = { + path: 'path', + virtual: 'virtual', +} as const; + +export type ROUTING = (typeof ROUTING)[keyof typeof ROUTING]; diff --git a/packages/elements/src/internals/machines/sign-in/router.machine.ts b/packages/elements/src/internals/machines/sign-in/router.machine.ts index ef2f53ac135..aff9a67ddbd 100644 --- a/packages/elements/src/internals/machines/sign-in/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/router.machine.ts @@ -5,6 +5,7 @@ import { and, assign, enqueueActions, not, or, raise, sendTo, setup } from 'xsta import { ERROR_CODES, + ROUTING, SIGN_IN_DEFAULT_BASE_PATH, SIGN_UP_DEFAULT_BASE_PATH, SSO_CALLBACK_PATH_ROUTE, @@ -166,7 +167,12 @@ export const SignInRouterMachine = setup({ type: 'REDIRECT', params: { strategy: event.strategy, - redirectUrl: context.redirectUrl, + redirectUrl: `${ + context.router?.mode === ROUTING.virtual + ? context.clerk.__unstable__environment?.displayConfig.signInUrl + : context.router?.basePath + }${SSO_CALLBACK_PATH_ROUTE}`, + redirectUrlComplete: context.clerk.buildAfterSignInUrl(), }, })), }, @@ -197,7 +203,6 @@ export const SignInRouterMachine = setup({ clerk: event.clerk, exampleMode: event.exampleMode || false, formRef: event.formRef, - redirectUrl: event.redirectUrl, loading: { isLoading: false, }, diff --git a/packages/elements/src/internals/machines/sign-up/router.machine.ts b/packages/elements/src/internals/machines/sign-up/router.machine.ts index ed4c3a9526b..79503b45390 100644 --- a/packages/elements/src/internals/machines/sign-up/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/router.machine.ts @@ -5,6 +5,7 @@ import { and, assign, enqueueActions, log, not, or, raise, sendTo, setup } from import { ERROR_CODES, + ROUTING, SEARCH_PARAMS, SIGN_IN_DEFAULT_BASE_PATH, SIGN_UP_DEFAULT_BASE_PATH, @@ -165,7 +166,12 @@ export const SignUpRouterMachine = setup({ type: 'REDIRECT', params: { strategy: event.strategy, - redirectUrl: context.redirectUrl, + redirectUrl: `${ + context.router?.mode === ROUTING.virtual + ? context.clerk.__unstable__environment?.displayConfig.signUpUrl + : context.router?.basePath + }${SSO_CALLBACK_PATH_ROUTE}`, + redirectUrlComplete: context.clerk.buildAfterSignUpUrl(), }, })), }, @@ -194,7 +200,6 @@ export const SignUpRouterMachine = setup({ INIT: { actions: assign(({ event }) => ({ clerk: event.clerk, - redirectUrl: event.redirectUrl, router: event.router, signInPath: event.signInPath || SIGN_IN_DEFAULT_BASE_PATH, loading: { diff --git a/packages/elements/src/internals/machines/third-party/third-party.actors.ts b/packages/elements/src/internals/machines/third-party/third-party.actors.ts index e4fe48b6389..519e2153f0a 100644 --- a/packages/elements/src/internals/machines/third-party/third-party.actors.ts +++ b/packages/elements/src/internals/machines/third-party/third-party.actors.ts @@ -8,7 +8,6 @@ import type { SetOptional } from 'type-fest'; import type { AnyActorRef, AnyEventObject } from 'xstate'; import { fromCallback, fromPromise } from 'xstate'; -import { SSO_CALLBACK_PATH_ROUTE } from '~/internals/constants'; import { ClerkElementsRuntimeError } from '~/internals/errors'; import type { WithParams, WithUnsafeMetadata } from '~/internals/machines/shared'; import { ClerkJSNavigationEvent, isClerkJSNavigationEvent } from '~/internals/machines/utils/clerkjs'; @@ -27,13 +26,12 @@ export type AuthenticateWithRedirectInput = ( ) & { basePath: string; parent: AnyActorRef }; // TODO: Fix circular dependency export const redirect = fromPromise( - async ({ input: { basePath, flow, params, parent } }) => { + async ({ input: { flow, params, parent } }) => { const clerk: LoadedClerk = parent.getSnapshot().context.clerk; - const path = clerk.buildUrlWithAuth(`${basePath}${SSO_CALLBACK_PATH_ROUTE}`); return clerk.client[flow].authenticateWithRedirect({ - redirectUrl: path, - redirectUrlComplete: path, + redirectUrl: clerk.buildUrlWithAuth(params.redirectUrl || '/'), + redirectUrlComplete: clerk.buildUrlWithAuth(params.redirectUrlComplete || '/'), ...params, }); }, @@ -80,8 +78,6 @@ export const handleRedirectCallback = fromCallback { assertEvent(event, 'REDIRECT'); - const clerk: LoadedClerk = context.parent.getSnapshot().context.clerk; - const redirectUrl = clerk.buildUrlWithAuth( - event.params.redirectUrl ?? `${context.basePath}${SSO_CALLBACK_PATH_ROUTE}`, - ); - const redirectUrlComplete = event.params.redirectUrlComplete || redirectUrl; - return { basePath: context.basePath, flow: context.flow, - params: { - redirectUrl, - redirectUrlComplete, - ...event.params, - }, + params: event.params, parent: context.parent, }; }, diff --git a/packages/elements/src/internals/machines/types/router.types.ts b/packages/elements/src/internals/machines/types/router.types.ts index 4d764a71879..904fb56dd4e 100644 --- a/packages/elements/src/internals/machines/types/router.types.ts +++ b/packages/elements/src/internals/machines/types/router.types.ts @@ -45,7 +45,6 @@ export type BaseRouterRedirectEvent = export interface BaseRouterInput { clerk: LoadedClerk; - redirectUrl?: string; router?: ClerkRouter; exampleMode?: boolean; } @@ -55,7 +54,6 @@ export interface BaseRouterInput { export interface BaseRouterContext { clerk: LoadedClerk; error?: ClerkElementsError; - redirectUrl?: string; router?: ClerkRouter; exampleMode?: boolean; } diff --git a/packages/elements/src/react/common/index.ts b/packages/elements/src/react/common/index.ts index da5a1d0880b..72504ef7901 100644 --- a/packages/elements/src/react/common/index.ts +++ b/packages/elements/src/react/common/index.ts @@ -5,8 +5,6 @@ export { Field, FieldError, FieldState, GlobalError, Input, Label, Submit } from export { Connection, Icon } from '~/react/common/connections'; export { Loading } from '~/react/common/loading'; -export { useVirtualRouter } from '~/react/router/virtual'; - export type { FormFieldErrorProps, FormErrorProps, diff --git a/packages/elements/src/react/router/index.ts b/packages/elements/src/react/router/index.ts index c804b50db51..4b634b9c9e9 100644 --- a/packages/elements/src/react/router/index.ts +++ b/packages/elements/src/react/router/index.ts @@ -1,4 +1,5 @@ export { useNextRouter } from './next'; export { Route, Router, useClerkRouter } from './react'; +export { useVirtualRouter } from './virtual'; export type { ClerkRouter, ClerkHostRouter } from './router'; diff --git a/packages/elements/src/react/router/router.ts b/packages/elements/src/react/router/router.ts index d2d7d88d14f..cde470cf8f2 100644 --- a/packages/elements/src/react/router/router.ts +++ b/packages/elements/src/react/router/router.ts @@ -1,12 +1,14 @@ import { withLeadingSlash, withoutTrailingSlash } from '@clerk/shared/url'; +import type { ROUTING } from '~/internals/constants'; + export const PRESERVED_QUERYSTRING_PARAMS = ['after_sign_in_url', 'after_sign_up_url', 'redirect_url']; /** * This type represents a generic router interface that Clerk relies on to interact with the host router. */ export type ClerkHostRouter = { - readonly mode: 'path' | 'virtual'; + readonly mode: ROUTING; readonly name: string; pathname: () => string; push: (path: string) => void; @@ -35,7 +37,7 @@ export type ClerkRouter = { /** * Mode of the router instance, path-based or virtual */ - readonly mode: 'path' | 'virtual'; + readonly mode: ROUTING; /** * Name of the router instance diff --git a/packages/elements/src/react/sign-in/root.tsx b/packages/elements/src/react/sign-in/root.tsx index 60923736c45..b33fa50a69f 100644 --- a/packages/elements/src/react/sign-in/root.tsx +++ b/packages/elements/src/react/sign-in/root.tsx @@ -3,13 +3,12 @@ import { eventComponentMounted } from '@clerk/shared/telemetry'; import React, { useEffect } from 'react'; import { createActor } from 'xstate'; -import { SIGN_IN_DEFAULT_BASE_PATH, SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants'; +import { ROUTING, SIGN_IN_DEFAULT_BASE_PATH, SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants'; import { FormStoreProvider, useFormStore } from '~/internals/machines/form/form.context'; import type { SignInRouterInitEvent } from '~/internals/machines/sign-in'; import { SignInRouterMachine } from '~/internals/machines/sign-in'; import { inspect } from '~/internals/utils/inspector'; -import type { ClerkHostRouter } from '~/react/router'; -import { Router, useClerkRouter, useNextRouter } from '~/react/router'; +import { Router, useClerkRouter, useNextRouter, useVirtualRouter } from '~/react/router'; import { SignInRouterCtx } from '~/react/sign-in/context'; import { Form } from '../common/form'; @@ -17,13 +16,12 @@ import { Form } from '../common/form'; type SignInFlowProviderProps = { children: React.ReactNode; exampleMode?: boolean; - redirectUrl?: string; }; const actor = createActor(SignInRouterMachine, { inspect }); actor.start(); -function SignInFlowProvider({ children, exampleMode, redirectUrl }: SignInFlowProviderProps) { +function SignInFlowProvider({ children, exampleMode }: SignInFlowProviderProps) { const clerk = useClerk(); const router = useClerkRouter(); const formRef = useFormStore(); @@ -38,7 +36,6 @@ function SignInFlowProvider({ children, exampleMode, redirectUrl }: SignInFlowPr clerk, exampleMode, formRef, - redirectUrl, router, signUpPath: SIGN_UP_DEFAULT_BASE_PATH, }; @@ -62,7 +59,7 @@ export type SignInRootProps = SignInFlowProviderProps & { * TODO: re-use usePathnameWithoutCatchAll from the next SDK */ path?: string; - router?: ClerkHostRouter; + routing?: ROUTING; }; /** @@ -85,11 +82,9 @@ export function SignInRoot({ exampleMode, fallback = null, path = SIGN_IN_DEFAULT_BASE_PATH, - redirectUrl, - router: routerFromProps, + routing, }: SignInRootProps): JSX.Element | null { const clerk = useClerk(); - const nextRouter = useNextRouter(); clerk.telemetry?.record( eventComponentMounted('Elements_SignInRoot', { @@ -100,7 +95,7 @@ export function SignInRoot({ ); // TODO: eventually we'll rely on the framework SDK to specify its host router, but for now we'll default to Next.js - const router = routerFromProps ?? nextRouter; + const router = (routing === ROUTING.virtual ? useVirtualRouter : useNextRouter)(); const isRootPath = path === router.pathname(); return ( @@ -109,10 +104,7 @@ export function SignInRoot({ router={router} > - + {isRootPath ? (
{fallback}
diff --git a/packages/elements/src/react/sign-up/root.tsx b/packages/elements/src/react/sign-up/root.tsx index c937dfe4b32..a4111369daa 100644 --- a/packages/elements/src/react/sign-up/root.tsx +++ b/packages/elements/src/react/sign-up/root.tsx @@ -4,13 +4,12 @@ import { useSelector } from '@xstate/react'; import { useEffect } from 'react'; import { createActor } from 'xstate'; -import { SIGN_IN_DEFAULT_BASE_PATH, SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants'; +import { ROUTING, SIGN_IN_DEFAULT_BASE_PATH, SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants'; import { FormStoreProvider, useFormStore } from '~/internals/machines/form/form.context'; import type { SignUpRouterInitEvent } from '~/internals/machines/sign-up'; import { SignUpRouterMachine } from '~/internals/machines/sign-up'; import { inspect } from '~/internals/utils/inspector'; -import type { ClerkHostRouter } from '~/react/router'; -import { Router, useClerkRouter, useNextRouter } from '~/react/router'; +import { Router, useClerkRouter, useNextRouter, useVirtualRouter } from '~/react/router'; import { SignUpRouterCtx } from '~/react/sign-up/context'; import { Form } from '../common/form'; @@ -18,7 +17,6 @@ import { Form } from '../common/form'; type SignUpFlowProviderProps = { children: React.ReactNode; exampleMode?: boolean; - redirectUrl?: string; }; const actor = createActor(SignUpRouterMachine, { inspect }); @@ -57,7 +55,7 @@ function SignUpFlowProvider({ children, exampleMode }: SignUpFlowProviderProps) export type SignUpRootProps = SignUpFlowProviderProps & { fallback?: React.ReactNode; path?: string; - router?: ClerkHostRouter; + routing?: ROUTING; }; /** @@ -80,10 +78,10 @@ export function SignUpRoot({ exampleMode, fallback = null, path = SIGN_UP_DEFAULT_BASE_PATH, - router: routerFromProps, + routing, }: SignUpRootProps): JSX.Element | null { const clerk = useClerk(); - const nextRouter = useNextRouter(); + // const nextRouter = useNextRouter(); clerk.telemetry?.record( eventComponentMounted('Elements_SignUpRoot', { @@ -94,7 +92,7 @@ export function SignUpRoot({ ); // TODO: eventually we'll rely on the framework SDK to specify its host router, but for now we'll default to Next.js - const router = routerFromProps ?? nextRouter; + const router = (routing === ROUTING.virtual ? useVirtualRouter : useNextRouter)(); const isRootPath = path === router.pathname(); return ( From 3c4cd45157e8a289cdeef96be4c4c484f5cf2f71 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 29 May 2024 18:40:11 -0400 Subject: [PATCH 6/6] chore(elements): Remove comment --- packages/elements/src/react/sign-up/root.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/elements/src/react/sign-up/root.tsx b/packages/elements/src/react/sign-up/root.tsx index a4111369daa..a3c25da2128 100644 --- a/packages/elements/src/react/sign-up/root.tsx +++ b/packages/elements/src/react/sign-up/root.tsx @@ -81,7 +81,6 @@ export function SignUpRoot({ routing, }: SignUpRootProps): JSX.Element | null { const clerk = useClerk(); - // const nextRouter = useNextRouter(); clerk.telemetry?.record( eventComponentMounted('Elements_SignUpRoot', {