diff --git a/.changeset/flat-actors-clean.md b/.changeset/flat-actors-clean.md new file mode 100644 index 0000000000..6a9db39070 --- /dev/null +++ b/.changeset/flat-actors-clean.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix combined flow routing. diff --git a/integration/tests/combined-sign-up-flow.test.ts b/integration/tests/combined-sign-up-flow.test.ts index e2c3d8adc2..93818d0b95 100644 --- a/integration/tests/combined-sign-up-flow.test.ts +++ b/integration/tests/combined-sign-up-flow.test.ts @@ -23,15 +23,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCombinedFlow] })('combine // Fill in sign in form await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); + await u.page.waitForAppUrl('/sign-in/create'); - // Verify email - await u.po.signUp.enterTestOtpCode(); - - await u.page.waitForAppUrl('/sign-in/create/continue'); + const prefilledEmail = await u.po.signUp.getEmailAddressInput().inputValue(); + expect(prefilledEmail).toBe(fakeUser.email); await u.po.signUp.setPassword(fakeUser.password); await u.po.signUp.continue(); + // Verify email + await u.po.signUp.enterTestOtpCode(); + // Check if user is signed in await u.po.expect.toBeSignedIn(); @@ -69,7 +71,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCombinedFlow] })('combine const u = createTestUtils({ app, page, context }); const fakeUser = u.services.users.createFakeUser({ fictionalEmail: true, - withPhoneNumber: true, + withPassword: true, withUsername: true, }); @@ -79,15 +81,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCombinedFlow] })('combine // Fill in sign in form await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); + await u.page.waitForAppUrl('/sign-in/create'); - // Verify email - await u.po.signUp.enterTestOtpCode(); - - await u.page.waitForAppUrl('/sign-in/create/continue'); + const prefilledEmail = await u.po.signUp.getEmailAddressInput().inputValue(); + expect(prefilledEmail).toBe(fakeUser.email); await u.po.signUp.setPassword(fakeUser.password); await u.po.signUp.continue(); + // Verify email + await u.po.signUp.enterTestOtpCode(); + // Check if user is signed in await u.po.expect.toBeSignedIn(); diff --git a/packages/clerk-js/src/ui/common/redirects.ts b/packages/clerk-js/src/ui/common/redirects.ts index 347b45bf9a..f8bfaf6a36 100644 --- a/packages/clerk-js/src/ui/common/redirects.ts +++ b/packages/clerk-js/src/ui/common/redirects.ts @@ -1,8 +1,8 @@ import { buildURL } from '../../utils/url'; import type { SignInContextType, SignUpContextType, UserProfileContextType } from './../contexts'; -const SSO_CALLBACK_PATH_ROUTE = '/sso-callback'; -const MAGIC_LINK_VERIFY_PATH_ROUTE = '/verify'; +export const SSO_CALLBACK_PATH_ROUTE = '/sso-callback'; +export const MAGIC_LINK_VERIFY_PATH_ROUTE = '/verify'; export function buildEmailLinkRedirectUrl( ctx: SignInContextType | SignUpContextType | UserProfileContextType, @@ -43,7 +43,13 @@ type BuildRedirectUrlParams = { endpoint: string; }; -const buildRedirectUrl = ({ routing, authQueryString, baseUrl, path, endpoint }: BuildRedirectUrlParams): string => { +export const buildRedirectUrl = ({ + routing, + authQueryString, + baseUrl, + path, + endpoint, +}: BuildRedirectUrlParams): string => { // If a routing strategy is not provided, default to hash routing // All routing strategies know how to convert a hash-based url to their own format // Example: navigating from a hash-based to a path-based component, diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index ea783fc013..dde7ba7278 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -2,7 +2,9 @@ import { useClerk } from '@clerk/shared/react'; import type { SignInModalProps, SignInProps } from '@clerk/types'; import React from 'react'; +import { normalizeRoutingOptions } from '../../../utils/normalizeRoutingOptions'; import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; +import type { SignUpContextType } from '../../contexts'; import { SignInContext, SignUpContext, @@ -145,14 +147,16 @@ function SignInRoutes(): JSX.Element { function SignInRoot() { const signInContext = useSignInContext(); + const normalizedSignUpContext = { + componentName: 'SignUp', + ...signInContext.__experimental?.combinedProps, + emailLinkRedirectUrl: signInContext.emailLinkRedirectUrl, + ssoCallbackUrl: signInContext.ssoCallbackUrl, + ...normalizeRoutingOptions({ routing: signInContext?.routing, path: signInContext?.path }), + } as SignUpContextType; return ( - + ); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index a35371b199..a4895912d6 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -387,6 +387,7 @@ export function _SignInStart(): JSX.Element { signUpMode: userSettings.signUp.mode, redirectUrl, redirectUrlComplete, + passwordEnabled: userSettings.attributes.password.required, }); } else { handleError(e, [identifierField, instantPasswordField], card.setError); diff --git a/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts b/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts index bad25a763d..60ff47cb99 100644 --- a/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts +++ b/packages/clerk-js/src/ui/components/SignIn/handleCombinedFlowTransfer.ts @@ -14,6 +14,7 @@ type HandleCombinedFlowTransferProps = { handleError: (err: any) => void; redirectUrl?: string; redirectUrlComplete?: string; + passwordEnabled: boolean; }; /** @@ -31,6 +32,7 @@ export function handleCombinedFlowTransfer({ handleError, redirectUrl, redirectUrlComplete, + passwordEnabled, }: HandleCombinedFlowTransferProps): Promise | void { if (signUpMode === SIGN_UP_MODES.WAITLIST) { const waitlistUrl = clerk.buildWaitlistUrl( @@ -51,11 +53,12 @@ export function handleCombinedFlowTransfer({ paramsToForward.set('__clerk_ticket', organizationTicket); } - // Attempt to transfer directly to sign up verification if email or phone was used and there are no optional fields. The signUp.create() call will + // Attempt to transfer directly to sign up verification if email or phone was used, there are no optional fields, and password is not enabled. The signUp.create() call will // inform us if the instance is eligible for moving directly to verification. if ( - (!hasOptionalFields(clerk.client.signUp) && identifierAttribute === 'emailAddress') || - identifierAttribute === 'phoneNumber' + !passwordEnabled && + !hasOptionalFields(clerk.client.signUp) && + (identifierAttribute === 'emailAddress' || identifierAttribute === 'phoneNumber') ) { return clerk.client.signUp .create({ diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx index a5b701e4c9..b1a9aec6c0 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx @@ -3,8 +3,7 @@ import type { SignUpResource } from '@clerk/types'; import React from 'react'; import { EmailLinkStatusCard } from '../../common'; -import { buildEmailLinkRedirectUrl } from '../../common/redirects'; -import { useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; +import { useCoreSignUp, useSignUpContext } from '../../contexts'; import { Flow, localizationKeys, useLocalizations } from '../../customizables'; import { VerificationLinkCard } from '../../elements'; import { useCardState } from '../../elements/contexts'; @@ -19,7 +18,6 @@ export const SignUpEmailLinkCard = () => { const signUpContext = useSignUpContext(); const { afterSignUpUrl } = signUpContext; const card = useCardState(); - const { displayConfig } = useEnvironment(); const { navigate } = useRouter(); const { setActive } = useClerk(); const [showVerifyModal, setShowVerifyModal] = React.useState(false); @@ -36,7 +34,9 @@ export const SignUpEmailLinkCard = () => { }; const startEmailLinkVerification = () => { - return startEmailLinkFlow({ redirectUrl: buildEmailLinkRedirectUrl(signUpContext, displayConfig.signUpUrl) }) + return startEmailLinkFlow({ + redirectUrl: signUpContext.emailLinkRedirectUrl, + }) .then(res => handleVerificationResult(res)) .catch(err => { handleError(err, [], card.setError); @@ -52,6 +52,7 @@ export const SignUpEmailLinkCard = () => { } else { await completeSignUpFlow({ signUp: su, + continuePath: '../continue', verifyEmailPath: '../verify-email-address', verifyPhonePath: '../verify-phone-number', handleComplete: () => setActive({ session: su.createdSessionId, redirectUrl: afterSignUpUrl }), diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpSocialButtons.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpSocialButtons.tsx index 9ae045a8a6..c662d07257 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpSocialButtons.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpSocialButtons.tsx @@ -2,9 +2,7 @@ import { useClerk } from '@clerk/shared/react'; import type { OAuthStrategy } from '@clerk/types'; import React from 'react'; -import { buildSSOCallbackURL } from '../../common/redirects'; import { useCoreSignUp, useSignUpContext } from '../../contexts'; -import { useEnvironment } from '../../contexts/EnvironmentContext'; import { useCardState } from '../../elements'; import type { SocialButtonsProps } from '../../elements/SocialButtons'; import { SocialButtons } from '../../elements/SocialButtons'; @@ -17,10 +15,9 @@ export const SignUpSocialButtons = React.memo((props: SignUpSocialButtonsProps) const clerk = useClerk(); const { navigate } = useRouter(); const card = useCardState(); - const { displayConfig } = useEnvironment(); const ctx = useSignUpContext(); const signUp = useCoreSignUp(); - const redirectUrl = buildSSOCallbackURL(ctx, displayConfig.signUpUrl); + const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignUpUrl || '/'; const { continueSignUp = false, ...rest } = props; diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index 3f000a1fe5..879c48034e 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { ERROR_CODES, SIGN_UP_MODES } from '../../../core/constants'; import { getClerkQueryParam, removeClerkQueryParam } from '../../../utils/getClerkQueryParam'; -import { buildSSOCallbackURL, withRedirectToAfterSignUp } from '../../common'; +import { withRedirectToAfterSignUp } from '../../common'; import { SignInContext, useCoreSignUp, useEnvironment, useOptions, useSignUpContext } from '../../contexts'; import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables'; import { @@ -34,7 +34,7 @@ function _SignUpStart(): JSX.Element { const status = useLoadingStatus(); const signUp = useCoreSignUp(); const { showOptionalFields } = useAppearance().parsedLayout; - const { userSettings, displayConfig } = useEnvironment(); + const { userSettings } = useEnvironment(); const { navigate } = useRouter(); const { attributes } = userSettings; const { setActive } = useClerk(); @@ -234,7 +234,7 @@ function _SignUpStart(): JSX.Element { card.setLoading(); card.setError(undefined); - const redirectUrl = buildSSOCallbackURL(ctx, displayConfig.signUpUrl); + const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignUpUrl || '/'; return signUp diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index e089e0f1b9..772345947c 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -4,6 +4,7 @@ import { createContext, useContext, useMemo } from 'react'; import { SIGN_IN_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; +import { buildRedirectUrl, MAGIC_LINK_VERIFY_PATH_ROUTE, SSO_CALLBACK_PATH_ROUTE } from '../../common/redirects'; import { useEnvironment, useOptions } from '../../contexts'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; @@ -21,6 +22,8 @@ export type SignInContextType = SignInCtx & { afterSignInUrl: string; transferable: boolean; waitlistUrl: string; + emailLinkRedirectUrl: string; + ssoCallbackUrl: string; }; export const SignInContext = createContext(null); @@ -32,14 +35,14 @@ export const useSignInContext = (): SignInContextType => { const { queryParams, queryString } = useRouter(); const options = useOptions(); const clerk = useClerk(); + const isCombinedFlow = options.experimental?.combinedFlow; if (context === null || context.componentName !== 'SignIn') { throw new Error(`Clerk: useSignInContext called outside of the mounted SignIn component.`); } const { componentName, mode, ..._ctx } = context; - const ctx = _ctx.__experimental?.combinedProps || _ctx; - + const ctx = _ctx.__experimental?.combinedProps ? { ..._ctx, ..._ctx.__experimental?.combinedProps } : _ctx; const initialValuesFromQueryParams = useMemo( () => getInitialValuesFromQueryParams(queryString, SIGN_IN_INITIAL_VALUE_KEYS), [], @@ -65,15 +68,33 @@ export const useSignInContext = (): SignInContextType => { // SignIn's own options won't have a `signInUrl` property, so we have to get the value // from the `path` prop instead, when the routing is set to 'path'. let signInUrl = (ctx.routing === 'path' && ctx.path) || options.signInUrl || displayConfig.signInUrl; - let signUpUrl = ctx.signUpUrl || options.signUpUrl || displayConfig.signUpUrl; + let signUpUrl = isCombinedFlow + ? (ctx.routing === 'path' && ctx.path) || options.signUpUrl || displayConfig.signUpUrl + : ctx.signUpUrl || options.signUpUrl || displayConfig.signUpUrl; let waitlistUrl = ctx.waitlistUrl || options.waitlistUrl || displayConfig.waitlistUrl; const preservedParams = redirectUrls.getPreservedSearchParams(); signInUrl = buildURL({ base: signInUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); waitlistUrl = buildURL({ base: waitlistUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); + const emailLinkRedirectUrl = buildRedirectUrl({ + routing: ctx.routing, + baseUrl: signUpUrl, + authQueryString: '', + path: ctx.path, + endpoint: options.experimental?.combinedFlow + ? '/create' + MAGIC_LINK_VERIFY_PATH_ROUTE + : MAGIC_LINK_VERIFY_PATH_ROUTE, + }); + const ssoCallbackUrl = buildRedirectUrl({ + routing: ctx.routing, + baseUrl: signUpUrl, + authQueryString: '', + path: ctx.path, + endpoint: options.experimental?.combinedFlow ? '/create' + SSO_CALLBACK_PATH_ROUTE : SSO_CALLBACK_PATH_ROUTE, + }); - if (options.experimental?.combinedFlow) { + if (isCombinedFlow) { signUpUrl = buildURL( { base: signInUrl, hashPath: '/create', hashSearchParams: [queryParams, preservedParams] }, { stringify: true }, @@ -83,7 +104,7 @@ export const useSignInContext = (): SignInContextType => { const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); return { - ...ctx, + ...(ctx as SignInCtx), transferable: ctx.transferable ?? true, componentName, signUpUrl, @@ -91,10 +112,12 @@ export const useSignInContext = (): SignInContextType => { waitlistUrl, afterSignInUrl, afterSignUpUrl, + emailLinkRedirectUrl, + ssoCallbackUrl, navigateAfterSignIn, signUpContinueUrl, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString: redirectUrls.toSearchParams().toString(), - }; + } as SignInContextType; }; diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 643525c4ef..e142ac4e5a 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -4,6 +4,7 @@ import { createContext, useContext, useMemo } from 'react'; import { SIGN_UP_INITIAL_VALUE_KEYS } from '../../../core/constants'; import { buildURL } from '../../../utils'; import { RedirectUrls } from '../../../utils/redirectUrls'; +import { buildRedirectUrl, MAGIC_LINK_VERIFY_PATH_ROUTE, SSO_CALLBACK_PATH_ROUTE } from '../../common/redirects'; import { useEnvironment, useOptions } from '../../contexts'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; @@ -20,6 +21,8 @@ export type SignUpContextType = SignUpCtx & { afterSignUpUrl: string; afterSignInUrl: string; waitlistUrl: string; + emailLinkRedirectUrl: string; + ssoCallbackUrl: string; }; export const SignUpContext = createContext(null); @@ -71,6 +74,27 @@ export const useSignUpContext = (): SignUpContextType => { signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); waitlistUrl = buildURL({ base: waitlistUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); + const emailLinkRedirectUrl = + ctx.emailLinkRedirectUrl ?? + buildRedirectUrl({ + routing: ctx.routing, + baseUrl: signUpUrl, + authQueryString: '', + path: ctx.path, + endpoint: options.experimental?.combinedFlow + ? '/create' + MAGIC_LINK_VERIFY_PATH_ROUTE + : MAGIC_LINK_VERIFY_PATH_ROUTE, + }); + const ssoCallbackUrl = + ctx.ssoCallbackUrl ?? + buildRedirectUrl({ + routing: ctx.routing, + baseUrl: signUpUrl, + authQueryString: '', + path: ctx.path, + endpoint: options.experimental?.combinedFlow ? '/create' + SSO_CALLBACK_PATH_ROUTE : SSO_CALLBACK_PATH_ROUTE, + }); + // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true }); @@ -83,6 +107,8 @@ export const useSignUpContext = (): SignUpContextType => { secondFactorUrl, afterSignUpUrl, afterSignInUrl, + emailLinkRedirectUrl, + ssoCallbackUrl, navigateAfterSignUp, queryParams, initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index d9af51adce..e90ebfff24 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -58,6 +58,8 @@ export type UserProfileCtx = UserProfileProps & { export type SignUpCtx = SignUpProps & { componentName: 'SignUp'; mode?: ComponentMode; + emailLinkRedirectUrl?: string; + ssoCallbackUrl?: string; }; export type UserButtonCtx = UserButtonProps & {