diff --git a/.changeset/fancy-candies-slide.md b/.changeset/fancy-candies-slide.md new file mode 100644 index 00000000000..852bf39a8a8 --- /dev/null +++ b/.changeset/fancy-candies-slide.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Support signUpIfMissing with strict enumeration protection and Clerk component diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 4255ae24385..5519718e8dd 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -110,7 +110,6 @@ import type { SignOut, SignOutCallback, SignOutOptions, - SignUpField, SignUpProps, SignUpRedirectOptions, SignUpResource, @@ -138,7 +137,6 @@ import { ModuleManager } from '@/utils/moduleManager'; import { ALLOWED_PROTOCOLS, buildURL, - completeSignUpFlow, createAllowedRedirectOrigins, createBeforeUnloadTracker, createPageLifecycle, @@ -151,6 +149,7 @@ import { isError, isOrganizationId, isRedirectForFAPIInitiatedFlow, + navigateToNextStepSignUp, removeClerkQueryParam, requiresUserInput, stripOrigin, @@ -2131,6 +2130,14 @@ export class Clerk implements ClerkInterface { throw new EmailLinkError(EmailLinkErrorCodeStatus.Expired); } else if (verificationStatus === 'client_mismatch') { throw new EmailLinkError(EmailLinkErrorCodeStatus.ClientMismatch); + } else if (verificationStatus === 'transferable') { + // signUpIfMissing flow: the email was verified but the user doesn't exist. + // The polling tab handles the actual sign-up transfer, so treat this + // the same as verified-on-other-device for the link-click tab. + if (typeof params.onVerifiedOnOtherDevice === 'function') { + params.onVerifiedOnOtherDevice(); + } + return; } else if (verificationStatus !== 'verified') { throw new EmailLinkError(EmailLinkErrorCodeStatus.Failed); } @@ -2262,39 +2269,15 @@ export class Clerk implements ClerkInterface { const redirectUrls = new RedirectUrls(this.#options, params); - const navigateToContinueSignUp = makeNavigate( + const continueSignUpUrl = params.continueSignUpUrl || - buildURL( - { - base: displayConfig.signUpUrl, - hashPath: '/continue', - }, - { stringify: true }, - ), - ); - - const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => { - if (missingFields.length) { - return navigateToContinueSignUp(); - } - - return completeSignUpFlow({ - signUp, - verifyEmailPath: - params.verifyEmailAddressUrl || - buildURL( - { - base: displayConfig.signUpUrl, - hashPath: '/verify-email-address', - }, - { stringify: true }, - ), - verifyPhonePath: - params.verifyPhoneNumberUrl || - buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }), - navigate, - }); - }; + buildURL({ base: displayConfig.signUpUrl, hashPath: '/continue' }, { stringify: true }); + const verifyEmailAddressUrl = + params.verifyEmailAddressUrl || + buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-email-address' }, { stringify: true }); + const verifyPhoneNumberUrl = + params.verifyPhoneNumberUrl || + buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }); const signInUrl = params.signInUrl || displayConfig.signInUrl; const signUpUrl = params.signUpUrl || displayConfig.signUpUrl; @@ -2393,7 +2376,14 @@ export class Clerk implements ClerkInterface { }, }); case 'missing_requirements': - return navigateToNextStepSignUp({ missingFields: res.missingFields }); + return navigateToNextStepSignUp({ + signUp, + missingFields: res.missingFields, + continueSignUpUrl, + verifyEmailAddressUrl, + verifyPhoneNumberUrl, + navigate, + }); default: clerkOAuthCallbackDidNotCompleteSignInSignUp('sign in'); } @@ -2444,7 +2434,14 @@ export class Clerk implements ClerkInterface { } if (su.externalAccountStatus === 'verified' && su.status === 'missing_requirements') { - return navigateToNextStepSignUp({ missingFields: signUp.missingFields }); + return navigateToNextStepSignUp({ + signUp, + missingFields: signUp.missingFields, + continueSignUpUrl, + verifyEmailAddressUrl, + verifyPhoneNumberUrl, + navigate, + }); } if (this.session?.currentTask) { diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 487ccb12f76..8c84bfbf4e4 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -308,7 +308,7 @@ export class SignIn extends BaseResource implements SignInResource { return this.reload() .then(res => { const status = res[verificationKey].status; - if (status === 'verified' || status === 'expired') { + if (status === 'verified' || status === 'expired' || status === 'transferable') { stop(); resolve(res); } @@ -1070,7 +1070,7 @@ class SignInFuture implements SignInFutureResource { try { const res = await this.#resource.__internal_baseGet(); const status = res.firstFactorVerification.status; - if (status === 'verified' || status === 'expired') { + if (status === 'verified' || status === 'expired' || status === 'transferable') { stop(); resolve(res); } diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index 93078997ebc..b70fd0b139a 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -1,4 +1,5 @@ import type { + AttackProtectionData, Attributes, EnterpriseSSOSettings, OAuthProviders, @@ -103,6 +104,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { name: 'passkey', }, }; + attackProtection: AttackProtectionData = { enumeration_protection: { enabled: false } }; enterpriseSSO: EnterpriseSSOSettings = { enabled: false, }; @@ -213,6 +215,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { this.attributes, ); this.actions = this.withDefault(data.actions, this.actions); + this.attackProtection = this.withDefault(data.attack_protection, this.attackProtection); this.enterpriseSSO = this.withDefault(data.enterprise_sso, this.enterpriseSSO); this.passkeySettings = this.withDefault(data.passkey_settings, this.passkeySettings); this.passwordSettings = data.password_settings @@ -251,6 +254,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { public __internal_toSnapshot(): UserSettingsJSONSnapshot { return { actions: this.actions, + attack_protection: this.attackProtection, attributes: this.attributes, passkey_settings: this.passkeySettings, password_settings: this.passwordSettings, diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 76d82b08de8..095c2c2a0f6 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -951,6 +951,37 @@ describe('SignIn', () => { expect.anything(), ); }); + + it('polls until firstFactorVerification status is transferable', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { status: 'unverified' }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { status: 'transferable' }, + }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + await signIn.__internal_future.emailLink.waitForVerification(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/client/sign_ins/signin_123', + }), + expect.anything(), + ); + }); }); describe('sendPhoneCode', () => { diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index 2a66443941e..db9d7631927 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './beforeUnloadTracker'; export * from './billing'; export * from '@clerk/shared/internal/clerk-js/completeSignUpFlow'; +export * from '@clerk/shared/internal/clerk-js/navigateToNextStepSignUp'; export * from '@clerk/shared/internal/clerk-js/email'; export * from '@clerk/shared/internal/clerk-js/encoders'; export * from './errors'; diff --git a/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts new file mode 100644 index 00000000000..d85df8b77d4 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SignUpField, SignUpResource } from '@/types'; + +import { navigateToNextStepSignUp } from '../navigateToNextStepSignUp'; + +const mockNavigate = vi.fn(); + +const URLS = { + continueSignUpUrl: 'https://app.test/sign-up/continue', + verifyEmailAddressUrl: 'https://app.test/sign-up/verify-email-address', + verifyPhoneNumberUrl: 'https://app.test/sign-up/verify-phone-number', +}; + +describe('navigateToNextStepSignUp', () => { + beforeEach(() => { + mockNavigate.mockReset(); + Object.defineProperty(window, 'location', { + value: { search: '' }, + writable: true, + }); + }); + + it('navigates to the continue page when there are missing fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: ['first_name'] as SignUpField[], + unverifiedFields: [], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + missingFields: signUp.missingFields, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.continueSignUpUrl); + }); + + it('navigates to verify-email-address when email is unverified and there are no missing fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: [] as SignUpField[], + unverifiedFields: ['email_address'], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + missingFields: signUp.missingFields, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.verifyEmailAddressUrl, { searchParams: new URLSearchParams() }); + }); + + it('navigates to verify-phone-number when phone is unverified and there are no missing fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: [] as SignUpField[], + unverifiedFields: ['phone_number'], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + missingFields: signUp.missingFields, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.verifyPhoneNumberUrl, { searchParams: new URLSearchParams() }); + }); + + it('prefers email verification over phone verification when both are unverified', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: [] as SignUpField[], + unverifiedFields: ['email_address', 'phone_number'], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + missingFields: signUp.missingFields, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.verifyEmailAddressUrl, { searchParams: new URLSearchParams() }); + }); + + it('prefers the continue page when there are both missing fields and unverified fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: ['first_name'] as SignUpField[], + unverifiedFields: ['email_address'], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + missingFields: signUp.missingFields, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.continueSignUpUrl); + }); + + it('does nothing when sign-up has no missing fields and no unverified fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: [] as SignUpField[], + unverifiedFields: [], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + missingFields: signUp.missingFields, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts b/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts new file mode 100644 index 00000000000..95497413be2 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts @@ -0,0 +1,45 @@ +import type { SignUpField, SignUpResource } from '../../types'; +import { completeSignUpFlow } from './completeSignUpFlow'; + +type NavigateToNextStepSignUpProps = { + signUp: SignUpResource; + missingFields: SignUpField[]; + continueSignUpUrl: string; + verifyEmailAddressUrl: string; + verifyPhoneNumberUrl: string; + navigate: (to: string, options?: { searchParams?: URLSearchParams }) => Promise; +}; + +/** + * Routes a sign-up that's still in `missing_requirements` to the appropriate + * next step: + * + * - If there are missing fields, go straight to the continue page so the user + * can fill them in. + * - Otherwise, hand off to `completeSignUpFlow` which routes unverified email + * or phone identifications to their respective verify pages. + * + * Used by both the OAuth callback handler and the sign-in `signUpIfMissing` + * transfer flow so they stay in lockstep. + * + * @internal + */ +export const navigateToNextStepSignUp = ({ + signUp, + missingFields, + continueSignUpUrl, + verifyEmailAddressUrl, + verifyPhoneNumberUrl, + navigate, +}: NavigateToNextStepSignUpProps): Promise | undefined => { + if (missingFields.length) { + return navigate(continueSignUpUrl); + } + + return completeSignUpFlow({ + signUp, + verifyEmailPath: verifyEmailAddressUrl, + verifyPhonePath: verifyPhoneNumberUrl, + navigate, + }); +}; diff --git a/packages/shared/src/internal/clerk-js/queryParams.ts b/packages/shared/src/internal/clerk-js/queryParams.ts index 8bea94a7c9b..d9466b843ea 100644 --- a/packages/shared/src/internal/clerk-js/queryParams.ts +++ b/packages/shared/src/internal/clerk-js/queryParams.ts @@ -20,9 +20,16 @@ const _ClerkQueryParams = [ type ClerkQueryParam = (typeof _ClerkQueryParams)[number]; /** - * Used for email link verification + * Possible values of `__clerk_status` returned from the email link verify + * endpoint. `transferable` is a transient internal state for the + * `signUpIfMissing` flow - the verification succeeded but the user does not + * exist, so the caller should perform a sign-up transfer. It is not a UI + * state and is never rendered directly; see `EmailLinkUIStatus`. */ -export type VerifyTokenStatus = 'verified' | (typeof EmailLinkErrorCodeStatus)[keyof typeof EmailLinkErrorCodeStatus]; +export type VerifyTokenStatus = + | 'verified' + | 'transferable' + | (typeof EmailLinkErrorCodeStatus)[keyof typeof EmailLinkErrorCodeStatus]; /** * Used for instance invitations and organization invitations diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index f8424d1eba6..f3684371e0e 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -82,6 +82,12 @@ export type UsernameSettingsData = { max_length: number; }; +export type AttackProtectionData = { + enumeration_protection: { + enabled: boolean; + }; +}; + export type PasskeySettingsData = { allow_autofill: boolean; show_sign_in_button: boolean; @@ -121,6 +127,7 @@ export interface UserSettingsJSON extends ClerkResourceJSON { password_settings: PasswordSettingsData; passkey_settings: PasskeySettingsData; username_settings: UsernameSettingsData; + attack_protection: AttackProtectionData; } export interface UserSettingsResource extends ClerkResource { @@ -135,6 +142,7 @@ export interface UserSettingsResource extends ClerkResource { signUp: SignUpData; passwordSettings: PasswordSettingsData; usernameSettings: UsernameSettingsData; + attackProtection: AttackProtectionData; passkeySettings: PasskeySettingsData; socialProviderStrategies: OAuthStrategy[]; authenticatableSocialStrategies: OAuthStrategy[]; diff --git a/packages/ui/src/common/EmailLinkStatusCard.tsx b/packages/ui/src/common/EmailLinkStatusCard.tsx index dabd2128d3c..80a09c0d816 100644 --- a/packages/ui/src/common/EmailLinkStatusCard.tsx +++ b/packages/ui/src/common/EmailLinkStatusCard.tsx @@ -10,7 +10,10 @@ import { ExclamationTriangle, SwitchArrows, TickShield } from '../icons'; import type { InternalTheme } from '../styledSystem'; import { animations } from '../styledSystem'; -export type EmailLinkUIStatus = VerifyTokenStatus | 'verified_switch_tab' | 'loading'; +// `transferable` is a transient internal state - by the time we render any +// status card the caller has already either completed the sign-up transfer +// or fallen through to the "verified on other device" path. +export type EmailLinkUIStatus = Exclude | 'verified_switch_tab' | 'loading'; type EmailLinkStatusCardProps = React.PropsWithChildren<{ title: LocalizationKey; diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index da2863fd3d9..e3b61325280 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -9,11 +9,12 @@ import type { VerificationCodeCardProps } from '@/ui/elements/VerificationCodeCa import { VerificationCodeCard } from '@/ui/elements/VerificationCodeCard'; import { handleError } from '@/ui/utils/errorHandler'; -import { useCoreSignIn, useSignInContext } from '../../contexts'; +import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; import { useFetch } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { type LocalizationKey } from '../../localization'; import { useRouter } from '../../router'; +import { handleSignUpIfMissingTransfer } from './handleSignUpIfMissingTransfer'; export type SignInFactorOneCodeCard = Pick< VerificationCodeCardProps, @@ -35,8 +36,10 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => const signIn = useCoreSignIn(); const card = useCardState(); const { navigate } = useRouter(); - const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); + const ctx = useSignInContext(); + const { afterSignInUrl, afterSignUpUrl, signUpUrl, isCombinedFlow, navigateOnSetActive } = ctx; const { setActive } = useClerk(); + const { userSettings } = useEnvironment(); const supportEmail = useSupportEmail(); const clerk = useClerk(); @@ -116,6 +119,20 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => return clerk.__internal_navigateWithError('..', err.errors[0]); } + if ( + isCombinedFlow && + userSettings.attackProtection.enumeration_protection.enabled && + signIn.firstFactorVerification.status === 'transferable' + ) { + return handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + signUpUrl, + unsafeMetadata: ctx.unsafeMetadata, + }).catch(reject); + } + return reject(err); }); }; diff --git a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx index 25a158a5044..2cdb9bafddc 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -9,11 +9,12 @@ import { handleError } from '@/ui/utils/errorHandler'; import { EmailLinkStatusCard } from '../../common'; import { buildVerificationRedirectUrl } from '../../common/redirects'; -import { useCoreSignIn, useSignInContext } from '../../contexts'; +import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; import { Flow, localizationKeys, useLocalizations } from '../../customizables'; import { useCardState } from '../../elements/contexts'; import { useEmailLink } from '../../hooks/useEmailLink'; import { useRouter } from '../../router/RouteContext'; +import { handleSignUpIfMissingTransfer } from './handleSignUpIfMissingTransfer'; type SignInFactorOneEmailLinkCardProps = Pick & { factor: EmailLinkFactor; @@ -26,10 +27,10 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const card = useCardState(); const signIn = useCoreSignIn(); const signInContext = useSignInContext(); - const { signInUrl } = signInContext; + const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl, isCombinedFlow } = signInContext; const { navigate } = useRouter(); - const { afterSignInUrl } = useSignInContext(); const { setActive } = useClerk(); + const { userSettings } = useEnvironment(); const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn); const [showVerifyModal, setShowVerifyModal] = React.useState(false); const clerk = useClerk(); @@ -63,6 +64,18 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const ver = si.firstFactorVerification; if (ver.status === 'expired') { card.setError(t(localizationKeys('formFieldError__verificationLinkExpired'))); + } else if ( + isCombinedFlow && + userSettings.attackProtection.enumeration_protection.enabled && + ver.status === 'transferable' + ) { + return handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + signUpUrl, + unsafeMetadata: signInContext.unsafeMetadata, + }); } else if (ver.verifiedFromTheSameClient()) { setShowVerifyModal(true); } else { diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 73cb247af7e..c4f9fbfeab1 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -375,7 +375,25 @@ function SignInStartInternal(): JSX.Element { } as any); } try { - const res = await safePasswordSignInForEnterpriseSSOInstance(signIn.create(buildSignInParams(fields)), fields); + // Sign up if missing sign-in-or-sign-up flows only support public sign-up + // instances and identifiers that can be verified out-of-band. + const hasPassword = fields.some(f => f.name === 'password' && !!f.value); + const signUpAttribute = getSignUpAttributeFromIdentifier(identifierField); + const supportsSignUpIfMissing = + signUpAttribute !== 'username' && userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC; + const shouldSignUpIfMissing = + isCombinedFlow && + userSettings.attackProtection.enumeration_protection.enabled && + supportsSignUpIfMissing && + !hasPassword; + + const res = await safePasswordSignInForEnterpriseSSOInstance( + signIn.create({ + ...buildSignInParams(fields), + ...(shouldSignUpIfMissing && { signUpIfMissing: true }), + }), + fields, + ); switch (res.status) { case 'needs_identifier': diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx new file mode 100644 index 00000000000..464200d05ee --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx @@ -0,0 +1,236 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { SignInResource } from '@clerk/shared/types'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { SignInFactorOne } from '../SignInFactorOne'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('SignInFactorOne sign-up-if-missing transfer', () => { + it('triggers sign-up transfer when attemptFirstFactor fails with transferable status', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + // The SDK updates firstFactorVerification on the resource *before* throwing + // the API error. This coupling is intentional — the component reads the + // resource status inside the catch block to decide whether to transfer. + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + fixtures.clerk.client.sessions = [{ id: 'sess_123' }] as any; + fixtures.signUp.create.mockResolvedValueOnce({ status: 'complete', createdSessionId: 'sess_123' } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.signUp.create).toHaveBeenCalledWith( + expect.objectContaining({ + transfer: true, + }), + ); + }); + }); + + it('navigates to create/continue when transfer results in missing_requirements', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + fixtures.signUp.create.mockResolvedValueOnce({ + status: 'missing_requirements', + missingFields: ['first_name'], + unverifiedFields: [], + } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.router.navigate).toHaveBeenCalledWith(expect.stringContaining('continue')); + }); + }); + + it('does not trigger transfer when enumeration protection is disabled', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + }); + }); + + it('does not trigger transfer when not in combined flow', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + }); + }); + + it('proceeds to second factor for existing users (no transfer)', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockResolvedValueOnce({ + status: 'needs_second_factor', + firstFactorVerification: { status: 'verified' }, + } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.router.navigate).toHaveBeenCalledWith('../factor-two'); + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + }); + }); + + it('triggers sign-up transfer when email link verification becomes transferable', async () => { + const email = 'test@clerk.com'; + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.withPreferredSignInStrategy({ strategy: 'password' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailLink: true, identifier: email }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.createEmailLinkFlow.mockReturnValue({ + startEmailLinkFlow: vi.fn().mockResolvedValue({ + status: 'needs_first_factor', + firstFactorVerification: { + status: 'transferable', + verifiedFromTheSameClient: () => false, + }, + }), + cancelEmailLinkFlow: vi.fn(), + } as any); + fixtures.signUp.create.mockResolvedValueOnce({ + status: 'missing_requirements', + missingFields: ['first_name'], + unverifiedFields: [], + } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.click(await screen.findByText('Use another method')); + await userEvent.click(await screen.findByText(`Email link to ${email}`)); + + await waitFor(() => { + expect(fixtures.signUp.create).toHaveBeenCalledWith( + expect.objectContaining({ + transfer: true, + }), + ); + expect(fixtures.router.navigate).toHaveBeenCalledWith(expect.stringContaining('continue')); + }); + }); + + it('surfaces transfer errors instead of leaving the code form loading', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + fixtures.signUp.create.mockResolvedValueOnce({ status: 'abandoned' } as any); + + const { userEvent } = render(, { wrapper }); + const input = screen.getByLabelText(/Enter verification code/i); + + await userEvent.type(input, '123456'); + + await waitFor(() => { + expect(fixtures.signUp.create).toHaveBeenCalled(); + expect(input).toHaveValue(''); + expect(input).not.toBeDisabled(); + }); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 7b14917cbe5..899ba3fb685 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -606,6 +606,134 @@ describe('SignInStart', () => { }); }); + describe('signUpIfMissing', () => { + it('passes signUpIfMissing: true when combined flow and enumeration protection are enabled', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when enumeration protection is disabled', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when not in combined flow', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when password is present', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword({ required: true }); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { container, userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + const passwordField = container.querySelector('#password-field') as Element; + expect(passwordField).not.toBeNull(); + fireEvent.change(passwordField, { target: { value: 'some-password' } }); + const form = container.querySelector('form') as Element; + fireEvent.submit(form); + await waitFor(() => { + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + }); + + it('does not pass signUpIfMissing when sign-up mode is restricted', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + f.withRestrictedMode(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when sign-up mode is waitlist', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + f.withWaitlistMode(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when the identifier is a username', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUsername(); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/username/i), 'hello'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + }); + describe('ticket flow', () => { it('calls the appropriate resource function upon detecting the ticket', async () => { const { wrapper, fixtures } = await createFixtures(f => { diff --git a/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts new file mode 100644 index 00000000000..025136e5f06 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts @@ -0,0 +1,200 @@ +import type { LoadedClerk } from '@clerk/shared/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { handleSignUpIfMissingTransfer } from '../handleSignUpIfMissingTransfer'; + +const mockNavigate = vi.fn(); + +const createMockClerk = (signUpCreateResult: unknown = {}) => { + return { + client: { + sessions: [], + signUp: { + create: vi.fn().mockResolvedValue(signUpCreateResult), + }, + reload: vi.fn(), + }, + navigate: vi.fn(), + setActive: vi.fn(), + } as unknown as LoadedClerk; +}; + +describe('handleSignUpIfMissingTransfer', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should call signUp.create with transfer: true', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + signUpUrl: 'https://test.com/sign-up', + }); + + expect(clerk.client.signUp.create).toHaveBeenCalledWith({ + transfer: true, + unsafeMetadata: undefined, + }); + }); + + it('should pass unsafeMetadata to signUp.create', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }); + const unsafeMetadata = { foo: 'bar' }; + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + signUpUrl: 'https://test.com/sign-up', + unsafeMetadata, + }); + + expect(clerk.client.signUp.create).toHaveBeenCalledWith({ + transfer: true, + unsafeMetadata, + }); + }); + + it('should call setActive when sign-up status is complete', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + signUpUrl: 'https://test.com/sign-up', + }); + + expect(clerk.setActive).toHaveBeenCalledWith( + expect.objectContaining({ + session: 'sess_123', + }), + ); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('uses clerk.navigate with the decorated afterSignUpUrl when the session has no pending task', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }) as LoadedClerk & { + navigate: ReturnType; + setActive: ReturnType; + }; + + const decorateUrl = vi.fn((url: string) => `${url}?decorated`); + + clerk.setActive.mockImplementation(async params => { + await params.navigate({ + session: { currentTask: null } as any, + decorateUrl, + }); + }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + signUpUrl: 'https://test.com/sign-up', + }); + + expect(decorateUrl).toHaveBeenCalledWith('https://test.com'); + expect(clerk.navigate).toHaveBeenCalledWith('https://test.com?decorated'); + }); + + it('routes to the pending task via clerk.navigate when the session has a current task', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }) as LoadedClerk & { + navigate: ReturnType; + setActive: ReturnType; + }; + + clerk.setActive.mockImplementation(async params => { + await params.navigate({ + session: { currentTask: { key: 'choose-organization' } } as any, + decorateUrl: (url: string) => url, + }); + }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + signUpUrl: 'https://test.com/sign-up', + }); + + // navigateIfTaskExists builds an absolute task URL from signUpUrl and calls clerk.navigate + expect(clerk.navigate).toHaveBeenCalledTimes(1); + const target = (clerk.navigate as ReturnType).mock.calls[0][0] as string; + expect(target).toContain('choose-organization'); + }); + + it('routes to the continue page when sign-up has missing fields', async () => { + const clerk = createMockClerk({ + status: 'missing_requirements', + missingFields: ['first_name'], + unverifiedFields: [], + }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + signUpUrl: 'https://test.com/sign-up', + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0] as string).toContain('/continue'); + expect(clerk.setActive).not.toHaveBeenCalled(); + }); + + it('routes to verify-email-address when sign-up has unverified email and no missing fields', async () => { + const clerk = createMockClerk({ + status: 'missing_requirements', + missingFields: [], + unverifiedFields: ['email_address'], + }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + signUpUrl: 'https://test.com/sign-up', + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0] as string).toContain('/verify-email-address'); + expect(clerk.setActive).not.toHaveBeenCalled(); + }); + + it('routes to verify-phone-number when sign-up has unverified phone and no missing fields', async () => { + const clerk = createMockClerk({ + status: 'missing_requirements', + missingFields: [], + unverifiedFields: ['phone_number'], + }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + signUpUrl: 'https://test.com/sign-up', + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0] as string).toContain('/verify-phone-number'); + expect(clerk.setActive).not.toHaveBeenCalled(); + }); + + it('should throw on unexpected sign-up status', async () => { + const clerk = createMockClerk({ status: 'abandoned' }); + + await expect( + handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + signUpUrl: 'https://test.com/sign-up', + }), + ).rejects.toThrow('Unexpected sign-up status after transfer: abandoned'); + }); +}); diff --git a/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts new file mode 100644 index 00000000000..353d966fc74 --- /dev/null +++ b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts @@ -0,0 +1,78 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { navigateToNextStepSignUp } from '@clerk/shared/internal/clerk-js/navigateToNextStepSignUp'; +import { navigateIfTaskExists } from '@clerk/shared/internal/clerk-js/sessionTasks'; +import { buildURL } from '@clerk/shared/internal/clerk-js/url'; +import type { LoadedClerk } from '@clerk/shared/types'; + +import type { RouteContextValue } from '../../router/RouteContext'; + +type HandleSignUpIfMissingTransferProps = { + clerk: LoadedClerk; + navigate: RouteContextValue['navigate']; + afterSignUpUrl: string; + signUpUrl: string; + unsafeMetadata?: SignUpUnsafeMetadata; +}; + +/** + * Handles transferring from sign-in to sign-up when the backend returns + * `firstFactorVerification.status === 'transferable'` (i.e. the user does not + * exist and `signUpIfMissing` was used). + * + * This mirrors the OAuth transfer handling in `_handleRedirectCallback`. + */ +export async function handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + signUpUrl, + unsafeMetadata, +}: HandleSignUpIfMissingTransferProps): Promise { + const res = await clerk.client.signUp.create({ + transfer: true, + unsafeMetadata, + }); + + switch (res.status) { + case 'complete': + return clerk.setActive({ + session: res.createdSessionId, + navigate: async ({ session, decorateUrl }) => { + if (!session.currentTask) { + // Absolute URL leaving the sign-in flow. Use clerk.navigate so we + // don't depend on the component-tree router, which is in a + // transitional state after setActive's #setTransitiveState. Wrap + // the destination in decorateUrl so Safari ITP is handled. + return clerk.navigate(decorateUrl(afterSignUpUrl)); + } + + // Pending task: route to the task within the sign-in component using + // an absolute URL built from signUpUrl. Wrap clerk.navigate to + // normalize the return type, since the public LoadedClerk type + // declares it as `Promise | void`. + await navigateIfTaskExists(session, { + baseUrl: signUpUrl, + navigate: async to => { + await clerk.navigate(to); + }, + }); + }, + }); + case 'missing_requirements': + // Same routing logic as the OAuth transfer flow: if there are missing + // fields, go to /continue; otherwise let completeSignUpFlow route any + // unverified email/phone identifications to their verify pages. + return navigateToNextStepSignUp({ + signUp: res, + missingFields: res.missingFields, + continueSignUpUrl: buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }), + verifyEmailAddressUrl: buildURL({ base: signUpUrl, hashPath: '/verify-email-address' }, { stringify: true }), + verifyPhoneNumberUrl: buildURL({ base: signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }), + navigate, + }); + default: + throw new ClerkRuntimeError(`Unexpected sign-up status after transfer: ${res.status}`, { + code: 'sign_up_transfer_unexpected_status', + }); + } +} diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index 460117de14a..07eafa56166 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -589,6 +589,14 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { us.sign_up.mfa = { required }; }; + const withEnumerationProtection = () => { + us.attack_protection = { + enumeration_protection: { + enabled: true, + }, + }; + }; + // TODO: Add the rest, consult pkg/generate/auth_config.go return { @@ -610,5 +618,6 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { withLegalConsent, withWaitlistMode, withMfaRequired, + withEnumerationProtection, }; }; diff --git a/packages/ui/src/test/fixtures.ts b/packages/ui/src/test/fixtures.ts index 8591a0df459..f20c77adaa3 100644 --- a/packages/ui/src/test/fixtures.ts +++ b/packages/ui/src/test/fixtures.ts @@ -235,6 +235,11 @@ const createBaseUserSettings = (): UserSettingsJSON => { }, password_settings: passwordSettingsConfig, passkey_settings: passkeySettingsConfig, + attack_protection: { + enumeration_protection: { + enabled: false, + }, + }, }; };