diff --git a/.changeset/lemon-crews-hammer.md b/.changeset/lemon-crews-hammer.md new file mode 100644 index 00000000000..c8c677ceced --- /dev/null +++ b/.changeset/lemon-crews-hammer.md @@ -0,0 +1,7 @@ +--- +'@clerk/elements': minor +--- + +- Adds virtual router to support modal scenarios +- 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 new file mode 100644 index 00000000000..6f9c5b49c51 --- /dev/null +++ b/packages/elements/examples/nextjs/app/modal/page.tsx @@ -0,0 +1,422 @@ +'use client'; + +import * as Clerk from '@clerk/elements/common'; +import * as SignIn from '@clerk/elements/sign-in'; +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'; + +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 [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.router?.mode === ROUTING.virtual + ? context.clerk.__unstable__environment?.displayConfig.signInUrl + : context.router?.basePath + }${SSO_CALLBACK_PATH_ROUTE}`, + redirectUrlComplete: context.clerk.buildAfterSignInUrl(), }, })), }, 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..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, @@ -161,10 +162,16 @@ 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.router?.mode === ROUTING.virtual + ? context.clerk.__unstable__environment?.displayConfig.signUpUrl + : context.router?.basePath + }${SSO_CALLBACK_PATH_ROUTE}`, + redirectUrlComplete: context.clerk.buildAfterSignUpUrl(), }, })), }, 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 = - event.params.redirectUrl || clerk.buildUrlWithAuth(`${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/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(), 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/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..cde470cf8f2 100644 --- a/packages/elements/src/react/router/router.ts +++ b/packages/elements/src/react/router/router.ts @@ -1,16 +1,20 @@ 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: ROUTING; + 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,6 +33,17 @@ 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 + */ + readonly mode: ROUTING; + + /** + * Name of the router instance + */ + readonly name: string; + /** * Navigates to the provided path via a history push */ @@ -119,6 +134,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..b33fa50a69f 100644 --- a/packages/elements/src/react/sign-in/root.tsx +++ b/packages/elements/src/react/sign-in/root.tsx @@ -3,12 +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 { 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'; @@ -51,16 +51,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; + routing?: ROUTING; }; /** @@ -80,22 +79,23 @@ export type SignInRootProps = { */ export function SignInRoot({ children, - path = SIGN_IN_DEFAULT_BASE_PATH, - fallback = null, exampleMode, + fallback = null, + path = SIGN_IN_DEFAULT_BASE_PATH, + routing, }: SignInRootProps): JSX.Element | null { const clerk = useClerk(); 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 = (routing === ROUTING.virtual ? useVirtualRouter : useNextRouter)(); const isRootPath = path === router.pathname(); return ( diff --git a/packages/elements/src/react/sign-up/root.tsx b/packages/elements/src/react/sign-up/root.tsx index 0eea9bf0dfd..a3c25da2128 100644 --- a/packages/elements/src/react/sign-up/root.tsx +++ b/packages/elements/src/react/sign-up/root.tsx @@ -4,12 +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 { 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'; @@ -52,11 +52,10 @@ function SignUpFlowProvider({ children, exampleMode }: SignUpFlowProviderProps) return isReady ? {children} : null; } -export type SignUpRootProps = { - path?: string; - children: React.ReactNode; +export type SignUpRootProps = SignUpFlowProviderProps & { fallback?: React.ReactNode; - exampleMode?: boolean; + path?: string; + routing?: ROUTING; }; /** @@ -76,9 +75,10 @@ export type SignUpRootProps = { */ export function SignUpRoot({ children, - path = SIGN_UP_DEFAULT_BASE_PATH, - fallback = null, exampleMode, + fallback = null, + path = SIGN_UP_DEFAULT_BASE_PATH, + routing, }: SignUpRootProps): JSX.Element | null { const clerk = useClerk(); @@ -91,7 +91,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 = (routing === ROUTING.virtual ? useVirtualRouter : useNextRouter)(); const isRootPath = path === router.pathname(); return (