diff --git a/.changeset/thirty-kings-trade.md b/.changeset/thirty-kings-trade.md new file mode 100644 index 00000000000..3f2f42f6104 --- /dev/null +++ b/.changeset/thirty-kings-trade.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Add `joinURL` helper to `@clerk/shared/url` diff --git a/package-lock.json b/package-lock.json index 9a572ad8e49..2e13204d4d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37846,7 +37846,7 @@ }, "packages/elements": { "name": "@clerk/elements", - "version": "0.0.0", + "version": "0.1.2", "license": "MIT", "dependencies": { "@clerk/clerk-react": "5.0.0-beta-v5.17", diff --git a/packages/elements/examples/nextjs/app/otp-playground/page.tsx b/packages/elements/examples/nextjs/app/otp-playground/page.tsx index 71a515e4b1d..f360622e007 100644 --- a/packages/elements/examples/nextjs/app/otp-playground/page.tsx +++ b/packages/elements/examples/nextjs/app/otp-playground/page.tsx @@ -7,7 +7,7 @@ import { AnimatePresence, motion } from 'framer-motion'; export default function Page() { return ( - +
diff --git a/packages/elements/examples/nextjs/app/page.tsx b/packages/elements/examples/nextjs/app/page.tsx index 77c5a01c2c8..4fba66245df 100644 --- a/packages/elements/examples/nextjs/app/page.tsx +++ b/packages/elements/examples/nextjs/app/page.tsx @@ -58,6 +58,19 @@ export default function Home() {

Sign up using Elements

+ +

+ OTP{' '} + + -> + +

+

OTP Playground

+ + (function CustomField( - { name, label }, - forwardedRef, -) { - const inputProps = - name === 'code' - ? { - render: OTPInputSegment, - } - : { - className: 'bg-tertiary rounded-sm px-2 py-1 border border-foreground data-[invalid]:border-red-500', - ref: forwardedRef, - }; +export const CustomField = forwardRef( + function CustomField({ name, label, required = false }, forwardedRef) { + const inputProps = + name === 'code' + ? { + render: OTPInputSegment, + className: 'flex gap-3', + required, + } + : { + className: 'bg-tertiary rounded-sm px-2 py-1 border border-foreground data-[invalid]:border-red-500', + ref: forwardedRef, + required, + }; - return ( - -
- - -
+ return ( + +
+ + +
- - {({ state }) =>
Field state: {state}
}
-
- ); -}); + + {({ state }) =>
Field state: {state}
}
+
+ ); + }, +); export const CustomSubmit = forwardRef>( function CustomButton(props, forwardedRef) { diff --git a/packages/elements/package.json b/packages/elements/package.json index ce41ed7c2f1..b4011e10430 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/elements", - "version": "0.1.1", + "version": "0.1.2", "description": "Clerk Elements", "keywords": [ "clerk", diff --git a/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts b/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts index 2dd659d5ced..3b4f84949e8 100644 --- a/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/sign-in.machine.ts @@ -1,5 +1,6 @@ import type { ClerkAPIResponseError } from '@clerk/shared/error'; import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { joinURL } from '@clerk/shared/url'; import type { LoadedClerk, OAuthStrategy, @@ -41,12 +42,14 @@ export interface SignInMachineContext extends MachineContext { resource: SignInResource | null; router: ClerkRouter; thirdPartyProviders: EnabledThirdPartyProviders; + signUpPath: string; } export interface SignInMachineInput { clerk: LoadedClerk; form: ActorRefFrom; router: ClerkRouter; + signUpPath: string; } export type SignInMachineTags = 'state:start' | 'state:first-factor' | 'state:second-factor' | 'external'; @@ -115,7 +118,11 @@ export const SignInMachine = setup({ console.error(event.error); }, navigateTo({ context }, { path }: { path: string }) { - context.router.replace(path); + const resolvedPath = joinURL(context.router.basePath, path); + context.router.replace(resolvedPath); + }, + navigateToSignUp({ context }) { + context.router.push(context.signUpPath); }, raiseFailure: raise(({ event }) => { assertActorEventError(event); @@ -143,8 +150,7 @@ export const SignInMachine = setup({ isCurrentFactorPassword: ({ context }) => context.currentFactor?.strategy === 'password', isCurrentFactorTOTP: ({ context }) => context.currentFactor?.strategy === 'totp', isCurrentPath: ({ context }, params: { path: string }) => { - const path = params?.path; - return path ? context.router.pathname() === path : false; + return context.router.match(params?.path); }, isLoggedIn: ({ context }) => Boolean(context.clerk.user), isSignInComplete: ({ context }) => context?.resource?.status === 'complete', @@ -179,6 +185,7 @@ export const SignInMachine = setup({ resource: null, router: input.router, thirdPartyProviders: getEnabledThirdPartyProviders(input.clerk.__unstable__environment), + signUpPath: input.signUpPath, }), initial: 'Init', on: { @@ -198,22 +205,22 @@ export const SignInMachine = setup({ }, { description: 'If the SignIn resource is empty, invoke the sign-in start flow', - guard: or([not('hasSignInResource'), { type: 'isCurrentPath', params: { path: '/sign-in' } }]), + guard: or([not('hasSignInResource'), { type: 'isCurrentPath', params: { path: '/' } }]), target: 'Start', }, { description: 'Go to FirstFactor flow state', - guard: and(['needsFirstFactor', { type: 'isCurrentPath', params: { path: '/sign-in/continue' } }]), + guard: and(['needsFirstFactor', { type: 'isCurrentPath', params: { path: '/continue' } }]), target: 'FirstFactor', }, { description: 'Go to SecondFactor flow state', - guard: and(['needsSecondFactor', { type: 'isCurrentPath', params: { path: '/sign-in/continue' } }]), + guard: and(['needsSecondFactor', { type: 'isCurrentPath', params: { path: '/continue' } }]), target: 'SecondFactor', }, { description: 'Go to SSO Callback state', - guard: { type: 'isCurrentPath', params: { path: '/sign-in/sso-callback' } }, + guard: { type: 'isCurrentPath', params: { path: '/sso-callback' } }, target: 'SSOCallback', }, { @@ -241,12 +248,20 @@ export const SignInMachine = setup({ Start: { id: 'Start', tags: 'state:start', - description: 'The intial state of the sign-in flow.', + description: 'The initial state of the sign-in flow.', initial: 'AwaitingInput', on: { 'AUTHENTICATE.OAUTH': '#SignIn.AuthenticatingWithRedirect', 'AUTHENTICATE.SAML': '#SignIn.AuthenticatingWithRedirect', }, + entry: [ + { + type: 'navigateTo', + params: { + path: '/', + }, + }, + ], onDone: [ { guard: 'isSignInComplete', @@ -297,7 +312,7 @@ export const SignInMachine = setup({ FirstFactor: { tags: 'state:first-factor', initial: 'DeterminingState', - entry: 'assignStartingFirstFactor', + entry: [{ type: 'navigateTo', params: { path: '/continue' } }, 'assignStartingFirstFactor'], onDone: [ { guard: 'isSignInComplete', @@ -385,7 +400,7 @@ export const SignInMachine = setup({ SecondFactor: { tags: 'state:second-factor', initial: 'DeterminingState', - entry: 'assignStartingSecondFactor', + entry: [{ type: 'navigateTo', params: { path: '/continue' } }, 'assignStartingSecondFactor'], onDone: [ { guard: 'isSignInComplete', @@ -507,48 +522,24 @@ export const SignInMachine = setup({ 'CLERKJS.NAVIGATE.RESET_PASSWORD': '#SignIn.NotImplemented', 'CLERKJS.NAVIGATE.SIGN_IN': { actions: [ - log('Navigating to sign in'), + log('Navigating to sign in root'), { type: 'navigateTo', params: { - path: '/sign-in', + path: '/', }, }, ], }, 'CLERKJS.NAVIGATE.SIGN_UP': { - actions: [ - log('Navigating to sign in'), - { - type: 'navigateTo', - params: { - path: '/sign-up', - }, - }, - ], + actions: [log('Navigating to sign up'), 'navigateToSignUp'], }, 'CLERKJS.NAVIGATE.VERIFICATION': { - actions: [ - log('Navigating to sign in'), - { - type: 'navigateTo', - params: { - path: '/sign-up', - }, - }, - ], + actions: [log('Navigating to sign in'), 'navigateToSignUp'], }, 'CLERKJS.NAVIGATE.CONTINUE': { description: 'Redirect to the sign-up flow', - actions: [ - log('Navigating to sign up'), - { - type: 'navigateTo', - params: { - path: '/sign-up', - }, - }, - ], + actions: [log('Navigating to sign up'), 'navigateToSignUp'], }, 'CLERKJS.NAVIGATE.*': { target: '#SignIn.Start', diff --git a/packages/elements/src/internals/machines/sign-up/sign-up.actors.ts b/packages/elements/src/internals/machines/sign-up/sign-up.actors.ts index b80363f2f46..2a417d1aa48 100644 --- a/packages/elements/src/internals/machines/sign-up/sign-up.actors.ts +++ b/packages/elements/src/internals/machines/sign-up/sign-up.actors.ts @@ -118,8 +118,6 @@ function fieldsToSignUpParams ): Pick { const params: SignUpUpdateParams = {}; - // TODO: Determine what takes priority - fields.forEach(({ value }, key) => { if (isSignUpParam(key) && value !== undefined) { params[key] = value as string; diff --git a/packages/elements/src/internals/machines/sign-up/sign-up.machine.ts b/packages/elements/src/internals/machines/sign-up/sign-up.machine.ts index 7c387b9e36e..9cc881631aa 100644 --- a/packages/elements/src/internals/machines/sign-up/sign-up.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/sign-up.machine.ts @@ -329,7 +329,7 @@ export const SignUpMachine = setup({ Start: { id: 'Start', tags: 'state:start', - description: 'The intial state of the sign-in flow.', + description: 'The intial state of the sign-up flow.', entry: 'assignThirdPartyProviders', initial: 'AwaitingInput', on: { diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index 82ef1da4f85..2b7e8b0e343 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -148,7 +148,7 @@ const useInput = ({ name: inputName, value: initialValue, type: inputType, ...pa const shouldBeHidden = false; const type = inputType ?? determineInputTypeFromName(name); - const Element = inputType === 'otp' ? OTPInput : RadixControl; + const Element = type === 'otp' ? OTPInput : RadixControl; let props = {}; if (inputType === 'otp') { diff --git a/packages/elements/src/react/router/router.ts b/packages/elements/src/react/router/router.ts index 1e0a700ad28..7643ea30df2 100644 --- a/packages/elements/src/react/router/router.ts +++ b/packages/elements/src/react/router/router.ts @@ -14,6 +14,10 @@ export type ClerkHostRouter = { * Internal Clerk router, used by Clerk components to interact with the host's router. */ export type ClerkRouter = { + /** + * The basePath the router is currently mounted on. + */ + basePath: string; /** * Creates a child router instance scoped to the provided base path. */ @@ -108,5 +112,6 @@ export function createClerkRouter(router: ClerkHostRouter, basePath: string = '/ replace, pathname: router.pathname, searchParams: router.searchParams, + basePath: normalizedBasePath, }; } diff --git a/packages/elements/src/react/sign-in/root.tsx b/packages/elements/src/react/sign-in/root.tsx index c01ba8b00c9..655f62f314e 100644 --- a/packages/elements/src/react/sign-in/root.tsx +++ b/packages/elements/src/react/sign-in/root.tsx @@ -31,6 +31,7 @@ function SignInFlowProvider({ children }: PropsWithChildren) { clerk, router, form, + signUpPath: '/sign-up', }, inspect: inspector?.inspect, }} @@ -40,14 +41,25 @@ function SignInFlowProvider({ children }: PropsWithChildren) { ); } -export function SignInRoot({ children }: PropsWithChildren): JSX.Element | null { +/** + * Root component for the sign-in flow. It sets up providers and state management for its children. + * Must wrap all sign-in related components. + * @example + * import { SignIn } from "@clerk/elements/sign-in" + * + * export default SignInPage = () => ( + * + * + * ) + */ +export function SignInRoot({ children, path = '/sign-in' }: PropsWithChildren<{ path?: string }>): JSX.Element | null { // 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(); return ( {/* TODO: Temporary hydration fix */} diff --git a/packages/elements/src/react/sign-up/verifications.tsx b/packages/elements/src/react/sign-up/verifications.tsx index 6dcf6cddad4..38585abb5f8 100644 --- a/packages/elements/src/react/sign-up/verifications.tsx +++ b/packages/elements/src/react/sign-up/verifications.tsx @@ -10,6 +10,19 @@ import { useActiveTags } from '../hooks/use-active-tags.hook'; export type SignUpVerifyProps = PropsWithChildren; +/** + * Renders its children when the user is in the verification step of the sign-up flow. This happens after the user has signed up but before their account is active & verified. + * @example + * import { SignUp, Verify } from "@clerk/elements/sign-up" + * + * export default SignUpPage = () => ( + * + * + * Please verify your account. + * + * + * ) + */ export function SignUpVerify({ children }: SignUpVerifyProps) { const ref = SignUpCtx.useActorRef(); const active = useActiveTags(ref, 'state:verification'); @@ -19,6 +32,20 @@ export function SignUpVerify({ children }: SignUpVerifyProps) { export type SignUpVerificationProps = PropsWithChildren<{ name: SignUpVerificationTags }>; +/** + * Conditionally renders its children based on the currently active verification method (e.g. password, email code, etc.). + * You'll most likely want to use this components inside a `` component to provide different verification methods during the verification step (after a user signed up but before their account is active & verified). + * @example + * import { SignUp, Verification } from "@clerk/elements/sign-up" + * + * export default SignUpPage = () => ( + * + * + * Please check your email for a verification link. + * + * + * ) + */ export function SignUpVerification({ children, name: tag }: SignUpVerificationProps) { const ref = SignUpCtx.useActorRef(); const active = useActiveTags(ref, tag); diff --git a/packages/shared/src/__tests__/url.test.ts b/packages/shared/src/__tests__/url.test.ts index 72af1c560b4..53aa478f019 100644 --- a/packages/shared/src/__tests__/url.test.ts +++ b/packages/shared/src/__tests__/url.test.ts @@ -1,4 +1,11 @@ -import { addClerkPrefix, getClerkJsMajorVersionOrTag, getScriptUrl, parseSearchParams, stripScheme } from '../url'; +import { + addClerkPrefix, + getClerkJsMajorVersionOrTag, + getScriptUrl, + joinURL, + parseSearchParams, + stripScheme, +} from '../url'; describe('parseSearchParams(queryString)', () => { it('parses query string and returns a URLSearchParams object', () => { @@ -96,3 +103,33 @@ describe('getScriptUrl', () => { ); }); }); + +describe('joinURL', () => { + const tests = [ + { input: [], out: '' }, + { input: ['/'], out: '/' }, + { input: [null, './'], out: './' }, + { input: ['/a'], out: '/a' }, + { input: ['a', 'b'], out: 'a/b' }, + { input: ['/a', 'b'], out: '/a/b' }, + { input: ['/', '/b'], out: '/b' }, + { input: ['a', 'b/', 'c'], out: 'a/b/c' }, + { input: ['a', 'b/', '/c'], out: 'a/b/c' }, + { input: ['/', './'], out: '/' }, + { input: ['/', './foo'], out: '/foo' }, + { input: ['/', './foo/'], out: '/foo/' }, + { input: ['/', './foo', 'bar'], out: '/foo/bar' }, + ]; + + for (const t of tests) { + test(JSON.stringify(t.input), () => { + // @ts-expect-error - Tests + expect(joinURL(...t.input)).toBe(t.out); + }); + } + + test('no arguments', () => { + // @ts-expect-error - Tests + expect(joinURL()).toBe(''); + }); +}); diff --git a/packages/shared/src/url.ts b/packages/shared/src/url.ts index a72ea2eafd7..96bd010c275 100644 --- a/packages/shared/src/url.ts +++ b/packages/shared/src/url.ts @@ -79,3 +79,57 @@ export function isCurrentDevAccountPortalOrigin(host: string): boolean { return host.endsWith(currentDevSuffix) && !host.endsWith('.clerk' + currentDevSuffix); }); } + +/* Functions below are taken from https://github.com/unjs/ufo/blob/main/src/utils.ts. LICENSE: MIT */ + +const TRAILING_SLASH_RE = /\/$|\/\?|\/#/; + +export function hasTrailingSlash(input = '', respectQueryAndFragment?: boolean): boolean { + if (!respectQueryAndFragment) { + return input.endsWith('/'); + } + return TRAILING_SLASH_RE.test(input); +} + +export function withTrailingSlash(input = '', respectQueryAndFragment?: boolean): string { + if (!respectQueryAndFragment) { + return input.endsWith('/') ? input : input + '/'; + } + if (hasTrailingSlash(input, true)) { + return input || '/'; + } + let path = input; + let fragment = ''; + const fragmentIndex = input.indexOf('#'); + if (fragmentIndex >= 0) { + path = input.slice(0, fragmentIndex); + fragment = input.slice(fragmentIndex); + if (!path) { + return fragment; + } + } + const [s0, ...s] = path.split('?'); + return s0 + '/' + (s.length > 0 ? `?${s.join('?')}` : '') + fragment; +} + +export function isNonEmptyURL(url: string) { + return url && url !== '/'; +} + +const JOIN_LEADING_SLASH_RE = /^\.?\//; + +export function joinURL(base: string, ...input: string[]): string { + let url = base || ''; + + for (const segment of input.filter(url => isNonEmptyURL(url))) { + if (url) { + // TODO: Handle .. when joining + const _segment = segment.replace(JOIN_LEADING_SLASH_RE, ''); + url = withTrailingSlash(url) + _segment; + } else { + url = segment; + } + } + + return url; +}