Skip to content

Commit

Permalink
fix(clerk-js): Combined flow routing (#4817)
Browse files Browse the repository at this point in the history
Co-authored-by: Dylan Staley <[email protected]>
Co-authored-by: Bryce Kalow <[email protected]>
  • Loading branch information
3 people authored Jan 7, 2025
1 parent e21d544 commit 2659108
Show file tree
Hide file tree
Showing 12 changed files with 110 additions and 38 deletions.
5 changes: 5 additions & 0 deletions .changeset/flat-actors-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix combined flow routing.
22 changes: 13 additions & 9 deletions integration/tests/combined-sign-up-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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,
});

Expand All @@ -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();

Expand Down
12 changes: 9 additions & 3 deletions packages/clerk-js/src/ui/common/redirects.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 10 additions & 6 deletions packages/clerk-js/src/ui/components/SignIn/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<SignUpContext.Provider
value={{
componentName: 'SignUp',
...signInContext.__experimental?.combinedProps,
}}
>
<SignUpContext.Provider value={normalizedSignUpContext}>
<SignInRoutes />
</SignUpContext.Provider>
);
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type HandleCombinedFlowTransferProps = {
handleError: (err: any) => void;
redirectUrl?: string;
redirectUrlComplete?: string;
passwordEnabled: boolean;
};

/**
Expand All @@ -31,6 +32,7 @@ export function handleCombinedFlowTransfer({
handleError,
redirectUrl,
redirectUrlComplete,
passwordEnabled,
}: HandleCombinedFlowTransferProps): Promise<unknown> | void {
if (signUpMode === SIGN_UP_MODES.WAITLIST) {
const waitlistUrl = clerk.buildWaitlistUrl(
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down
6 changes: 3 additions & 3 deletions packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
35 changes: 29 additions & 6 deletions packages/clerk-js/src/ui/contexts/components/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,8 @@ export type SignInContextType = SignInCtx & {
afterSignInUrl: string;
transferable: boolean;
waitlistUrl: string;
emailLinkRedirectUrl: string;
ssoCallbackUrl: string;
};

export const SignInContext = createContext<SignInCtx | null>(null);
Expand All @@ -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),
[],
Expand All @@ -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 },
Expand All @@ -83,18 +104,20 @@ export const useSignInContext = (): SignInContextType => {
const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true });

return {
...ctx,
...(ctx as SignInCtx),
transferable: ctx.transferable ?? true,
componentName,
signUpUrl,
signInUrl,
waitlistUrl,
afterSignInUrl,
afterSignUpUrl,
emailLinkRedirectUrl,
ssoCallbackUrl,
navigateAfterSignIn,
signUpContinueUrl,
queryParams,
initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams },
authQueryString: redirectUrls.toSearchParams().toString(),
};
} as SignInContextType;
};
26 changes: 26 additions & 0 deletions packages/clerk-js/src/ui/contexts/components/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,8 @@ export type SignUpContextType = SignUpCtx & {
afterSignUpUrl: string;
afterSignInUrl: string;
waitlistUrl: string;
emailLinkRedirectUrl: string;
ssoCallbackUrl: string;
};

export const SignUpContext = createContext<SignUpCtx | null>(null);
Expand Down Expand Up @@ -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 });

Expand All @@ -83,6 +107,8 @@ export const useSignUpContext = (): SignUpContextType => {
secondFactorUrl,
afterSignUpUrl,
afterSignInUrl,
emailLinkRedirectUrl,
ssoCallbackUrl,
navigateAfterSignUp,
queryParams,
initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams },
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export type UserProfileCtx = UserProfileProps & {
export type SignUpCtx = SignUpProps & {
componentName: 'SignUp';
mode?: ComponentMode;
emailLinkRedirectUrl?: string;
ssoCallbackUrl?: string;
};

export type UserButtonCtx = UserButtonProps & {
Expand Down

0 comments on commit 2659108

Please sign in to comment.