From 84d79f69fa426c7cc5bee88f8eb78a7d85272bff Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 25 Feb 2026 13:12:53 -0500 Subject: [PATCH 1/9] feat(ui): Support signUpIfMissing with Clerk component The `` component can already be used in a sign-in-or-sign-up flow (`CombinedFlow`) under certain conditions. When strict enumeration protection is enabled, make that combined flow pass the `signUpIfMissing` parameter to the backend to allow an enumeration-safe combined flow. Previously, attempting to use a combined flow with strict enumeration protection enabled was silently broken. Under the hood, the backend treats sign up if missing as an account transfer. We therefore add support for this account transfer logic when handling first factor verification in the combined sign in flow when strict enumeration protection is enabled. --- .changeset/fancy-candies-slide.md | 7 + packages/clerk-js/src/core/clerk.ts | 8 ++ .../clerk-js/src/core/resources/SignIn.ts | 2 +- .../src/core/resources/UserSettings.ts | 4 + packages/shared/src/errors/emailLinkError.ts | 1 + packages/shared/src/types/userSettings.ts | 8 ++ .../SignIn/SignInFactorOneCodeForm.tsx | 21 ++- .../SignIn/SignInFactorOneEmailLinkCard.tsx | 19 ++- .../ui/src/components/SignIn/SignInStart.tsx | 14 +- .../SignInFactorOneTransfer.test.tsx | 130 ++++++++++++++++++ .../SignIn/__tests__/SignInStart.test.tsx | 75 ++++++++++ .../handleSignUpIfMissingTransfer.test.ts | 103 ++++++++++++++ .../SignIn/handleSignUpIfMissingTransfer.ts | 47 +++++++ packages/ui/src/test/fixture-helpers.ts | 9 ++ packages/ui/src/test/fixtures.ts | 5 + 15 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 .changeset/fancy-candies-slide.md create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts create mode 100644 packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts 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 1423f8bf1a2..8b854c5dbd0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2116,6 +2116,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); } diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 487ccb12f76..285e4697a8a 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); } 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/shared/src/errors/emailLinkError.ts b/packages/shared/src/errors/emailLinkError.ts index 8c1055ea4a5..a353b4da6fb 100644 --- a/packages/shared/src/errors/emailLinkError.ts +++ b/packages/shared/src/errors/emailLinkError.ts @@ -24,4 +24,5 @@ export const EmailLinkErrorCodeStatus = { Expired: 'expired', Failed: 'failed', ClientMismatch: 'client_mismatch', + Transferable: 'transferable', } as const; diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index 149db66220f..a5bee6c462a 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -81,6 +81,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; @@ -120,6 +126,7 @@ export interface UserSettingsJSON extends ClerkResourceJSON { password_settings: PasswordSettingsData; passkey_settings: PasskeySettingsData; username_settings: UsernameSettingsData; + attack_protection: AttackProtectionData; } export interface UserSettingsResource extends ClerkResource { @@ -134,6 +141,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/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index da2863fd3d9..a8fd22e4a3d 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, navigateOnSetActive, isCombinedFlow } = 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, + navigateOnSetActive, + unsafeMetadata: ctx.unsafeMetadata, + }); + } + return reject(err); }); }; diff --git a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx index 25a158a5044..54fc14e7726 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, afterSignInUrl, afterSignUpUrl, isCombinedFlow, navigateOnSetActive } = 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, + navigateOnSetActive, + 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..e03f0c0c55b 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -375,7 +375,19 @@ 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 do not currently support password + // sign in, since this is not enumeration-safe. + const hasPassword = fields.some(f => f.name === 'password' && !!f.value); + const shouldSignUpIfMissing = + isCombinedFlow && userSettings.attackProtection.enumeration_protection.enabled && !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..a3b143b2ffb --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx @@ -0,0 +1,130 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { SignInResource } from '@clerk/shared/types'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it } 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)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + // Simulate SDK updating the resource before throwing (backend returns 404 with transferable in meta.client) + 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: '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' } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.router.navigate).toHaveBeenCalledWith('../create/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(); + }); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 7b14917cbe5..f6990fc37d8 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -606,6 +606,81 @@ 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, + }), + ); + }); + }); + }); + 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..13fde8cc756 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts @@ -0,0 +1,103 @@ +import type { LoadedClerk } from '@clerk/shared/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { handleSignUpIfMissingTransfer } from '../handleSignUpIfMissingTransfer'; + +const mockNavigate = vi.fn(); +const mockNavigateOnSetActive = vi.fn(); + +const createMockClerk = (signUpCreateResult: unknown = {}) => { + return { + client: { + signUp: { + create: vi.fn().mockResolvedValue(signUpCreateResult), + }, + }, + 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', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + 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', + navigateOnSetActive: mockNavigateOnSetActive, + 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', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + expect(clerk.setActive).toHaveBeenCalledWith( + expect.objectContaining({ + session: 'sess_123', + }), + ); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('should navigate to create/continue when sign-up status is missing_requirements', async () => { + const clerk = createMockClerk({ status: 'missing_requirements' }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + expect(mockNavigate).toHaveBeenCalledWith('../create/continue'); + 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', + navigateOnSetActive: mockNavigateOnSetActive, + }), + ).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..370906548a9 --- /dev/null +++ b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts @@ -0,0 +1,47 @@ +import type { DecorateUrl, LoadedClerk, SessionResource } from '@clerk/shared/types'; + +import type { RouteContextValue } from '../../router/RouteContext'; + +type HandleSignUpIfMissingTransferProps = { + clerk: LoadedClerk; + navigate: RouteContextValue['navigate']; + afterSignUpUrl: string; + navigateOnSetActive: (opts: { + session: SessionResource; + redirectUrl: string; + decorateUrl: DecorateUrl; + }) => Promise; + 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). + */ +export async function handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + navigateOnSetActive, + 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 }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); + }, + }); + case 'missing_requirements': + return navigate(`../create/continue`); + default: + throw new Error(`Unexpected sign-up status after transfer: ${res.status}`); + } +} diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index 45525aef82d..8a866a4469d 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -588,6 +588,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 { @@ -609,5 +617,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, + }, + }, }; }; From 40d8ceb126c410aa36bbeb39ab7f8d4eacaea062 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Mon, 2 Mar 2026 16:54:54 -0500 Subject: [PATCH 2/9] fix: Also add transferable status to SignInFuture email link This is connected to custom flows and was missed in the previous PRs supporting custom flows. Let's add it now while we are here. --- .../clerk-js/src/core/resources/SignIn.ts | 2 +- .../core/resources/__tests__/SignIn.test.ts | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 285e4697a8a..8c84bfbf4e4 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -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/__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', () => { From 16207c351bd75ae80f6487313fbca76bc4ac9df8 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Mon, 23 Mar 2026 14:14:20 -0400 Subject: [PATCH 3/9] fix: Explicitly guard restricted modes and username sign in --- .../ui/src/components/SignIn/SignInStart.tsx | 12 +++-- .../SignIn/__tests__/SignInStart.test.tsx | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index e03f0c0c55b..c4f9fbfeab1 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -375,11 +375,17 @@ function SignInStartInternal(): JSX.Element { } as any); } try { - // Sign up if missing sign-in-or-sign-up flows do not currently support password - // sign in, since this is not enumeration-safe. + // 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 && !hasPassword; + isCombinedFlow && + userSettings.attackProtection.enumeration_protection.enabled && + supportsSignUpIfMissing && + !hasPassword; const res = await safePasswordSignInForEnterpriseSSOInstance( signIn.create({ diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index f6990fc37d8..899ba3fb685 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -679,6 +679,59 @@ describe('SignInStart', () => { ); }); }); + + 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', () => { From c84148bd344a5bfc5cf217937c77f28bad1d862c Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Mon, 23 Mar 2026 14:25:25 -0400 Subject: [PATCH 4/9] fix: Improve error handling in transfer flow --- .../SignIn/SignInFactorOneCodeForm.tsx | 2 +- .../SignInFactorOneTransfer.test.tsx | 61 ++++++++++++++++++- .../SignIn/handleSignUpIfMissingTransfer.ts | 5 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index a8fd22e4a3d..02f6adfabda 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -130,7 +130,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => afterSignUpUrl, navigateOnSetActive, unsafeMetadata: ctx.unsafeMetadata, - }); + }).catch(reject); } return reject(err); diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx index a3b143b2ffb..31a734d2321 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx @@ -21,8 +21,10 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => { 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(() => { - // Simulate SDK updating the resource before throwing (backend returns 404 with transferable in meta.client) fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; return Promise.reject( new ClerkAPIResponseError('Error', { @@ -127,4 +129,61 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => { 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('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/handleSignUpIfMissingTransfer.ts b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts index 370906548a9..3c72e48d892 100644 --- a/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts +++ b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts @@ -1,3 +1,4 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import type { DecorateUrl, LoadedClerk, SessionResource } from '@clerk/shared/types'; import type { RouteContextValue } from '../../router/RouteContext'; @@ -42,6 +43,8 @@ export async function handleSignUpIfMissingTransfer({ case 'missing_requirements': return navigate(`../create/continue`); default: - throw new Error(`Unexpected sign-up status after transfer: ${res.status}`); + throw new ClerkRuntimeError(`Unexpected sign-up status after transfer: ${res.status}`, { + code: 'sign_up_transfer_unexpected_status', + }); } } From 7a4025b2df099c5504d4a19bc77ade673e34c4a6 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 8 Apr 2026 16:27:57 -0400 Subject: [PATCH 5/9] fix: Align sign up if missing with oauth transfer navigation --- packages/clerk-js/src/core/clerk.ts | 61 ++++---- packages/clerk-js/src/utils/index.ts | 1 + .../navigateToNextStepSignUp.test.ts | 130 ++++++++++++++++++ .../clerk-js/navigateToNextStepSignUp.ts | 45 ++++++ .../SignIn/SignInFactorOneCodeForm.tsx | 4 +- .../SignIn/SignInFactorOneEmailLinkCard.tsx | 4 +- .../SignInFactorOneTransfer.test.tsx | 53 ++++++- .../handleSignUpIfMissingTransfer.test.ts | 115 ++++++++++++++-- .../SignIn/handleSignUpIfMissingTransfer.ts | 42 ++++-- 9 files changed, 394 insertions(+), 61 deletions(-) create mode 100644 packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts create mode 100644 packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b35895ce4d3..c5839ea2d9e 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,7 @@ import { ModuleManager } from '@/utils/moduleManager'; import { ALLOWED_PROTOCOLS, buildURL, - completeSignUpFlow, + navigateToNextStepSignUp, createAllowedRedirectOrigins, createBeforeUnloadTracker, createPageLifecycle, @@ -2270,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; @@ -2401,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'); } @@ -2452,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/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/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index 02f6adfabda..97d77ab0cae 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -37,7 +37,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => const card = useCardState(); const { navigate } = useRouter(); const ctx = useSignInContext(); - const { afterSignInUrl, afterSignUpUrl, navigateOnSetActive, isCombinedFlow } = ctx; + const { afterSignInUrl, afterSignUpUrl, signUpUrl, isCombinedFlow } = ctx; const { setActive } = useClerk(); const { userSettings } = useEnvironment(); const supportEmail = useSupportEmail(); @@ -128,7 +128,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => clerk, navigate, afterSignUpUrl, - navigateOnSetActive, + signUpUrl, unsafeMetadata: ctx.unsafeMetadata, }).catch(reject); } diff --git a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx index 54fc14e7726..2cdb9bafddc 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -27,7 +27,7 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const card = useCardState(); const signIn = useCoreSignIn(); const signInContext = useSignInContext(); - const { signInUrl, afterSignInUrl, afterSignUpUrl, isCombinedFlow, navigateOnSetActive } = signInContext; + const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl, isCombinedFlow } = signInContext; const { navigate } = useRouter(); const { setActive } = useClerk(); const { userSettings } = useEnvironment(); @@ -73,7 +73,7 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard clerk, navigate, afterSignUpUrl, - navigateOnSetActive, + signUpUrl, unsafeMetadata: signInContext.unsafeMetadata, }); } else if (ver.verifiedFromTheSameClient()) { diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx index 31a734d2321..464200d05ee 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx @@ -1,7 +1,7 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; import type { SignInResource } from '@clerk/shared/types'; import { waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen } from '@/test/utils'; @@ -33,6 +33,7 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => { }), ); }); + fixtures.clerk.client.sessions = [{ id: 'sess_123' }] as any; fixtures.signUp.create.mockResolvedValueOnce({ status: 'complete', createdSessionId: 'sess_123' } as any); const { userEvent } = render(, { wrapper }); @@ -66,13 +67,17 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => { }), ); }); - fixtures.signUp.create.mockResolvedValueOnce({ status: 'missing_requirements' } as any); + 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('../create/continue'); + expect(fixtures.router.navigate).toHaveBeenCalledWith(expect.stringContaining('continue')); }); }); @@ -154,6 +159,48 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => { }); }); + 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(); diff --git a/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts index 13fde8cc756..025136e5f06 100644 --- a/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts @@ -4,15 +4,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleSignUpIfMissingTransfer } from '../handleSignUpIfMissingTransfer'; const mockNavigate = vi.fn(); -const mockNavigateOnSetActive = 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; }; @@ -29,7 +31,7 @@ describe('handleSignUpIfMissingTransfer', () => { clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - navigateOnSetActive: mockNavigateOnSetActive, + signUpUrl: 'https://test.com/sign-up', }); expect(clerk.client.signUp.create).toHaveBeenCalledWith({ @@ -46,7 +48,7 @@ describe('handleSignUpIfMissingTransfer', () => { clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - navigateOnSetActive: mockNavigateOnSetActive, + signUpUrl: 'https://test.com/sign-up', unsafeMetadata, }); @@ -63,7 +65,7 @@ describe('handleSignUpIfMissingTransfer', () => { clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - navigateOnSetActive: mockNavigateOnSetActive, + signUpUrl: 'https://test.com/sign-up', }); expect(clerk.setActive).toHaveBeenCalledWith( @@ -74,17 +76,112 @@ describe('handleSignUpIfMissingTransfer', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); - it('should navigate to create/continue when sign-up status is missing_requirements', async () => { - const clerk = createMockClerk({ status: 'missing_requirements' }); + 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', - navigateOnSetActive: mockNavigateOnSetActive, + signUpUrl: 'https://test.com/sign-up', }); - expect(mockNavigate).toHaveBeenCalledWith('../create/continue'); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0] as string).toContain('/verify-phone-number'); expect(clerk.setActive).not.toHaveBeenCalled(); }); @@ -96,7 +193,7 @@ describe('handleSignUpIfMissingTransfer', () => { clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - navigateOnSetActive: mockNavigateOnSetActive, + 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 index 3c72e48d892..962ad7e0e2a 100644 --- a/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts +++ b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts @@ -1,5 +1,8 @@ import { ClerkRuntimeError } from '@clerk/shared/error'; -import type { DecorateUrl, LoadedClerk, SessionResource } from '@clerk/shared/types'; +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'; @@ -7,11 +10,7 @@ type HandleSignUpIfMissingTransferProps = { clerk: LoadedClerk; navigate: RouteContextValue['navigate']; afterSignUpUrl: string; - navigateOnSetActive: (opts: { - session: SessionResource; - redirectUrl: string; - decorateUrl: DecorateUrl; - }) => Promise; + signUpUrl: string; unsafeMetadata?: SignUpUnsafeMetadata; }; @@ -19,12 +18,14 @@ type HandleSignUpIfMissingTransferProps = { * 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, - navigateOnSetActive, + signUpUrl, unsafeMetadata, }: HandleSignUpIfMissingTransferProps): Promise { const res = await clerk.client.signUp.create({ @@ -37,11 +38,34 @@ export async function handleSignUpIfMissingTransfer({ return clerk.setActive({ session: res.createdSessionId, navigate: async ({ session, decorateUrl }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, 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. + await navigateIfTaskExists(session, { + baseUrl: signUpUrl, + navigate: clerk.navigate, + }); }, }); case 'missing_requirements': - return navigate(`../create/continue`); + // 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', From 34c36bbb3cb975c76dd4941593349761b9defc52 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 8 Apr 2026 16:38:40 -0400 Subject: [PATCH 6/9] fix: Correct imports and function type --- .../ui/src/components/SignIn/SignInFactorOneCodeForm.tsx | 2 +- .../components/SignIn/handleSignUpIfMissingTransfer.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index 97d77ab0cae..e3b61325280 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -37,7 +37,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => const card = useCardState(); const { navigate } = useRouter(); const ctx = useSignInContext(); - const { afterSignInUrl, afterSignUpUrl, signUpUrl, isCombinedFlow } = ctx; + const { afterSignInUrl, afterSignUpUrl, signUpUrl, isCombinedFlow, navigateOnSetActive } = ctx; const { setActive } = useClerk(); const { userSettings } = useEnvironment(); const supportEmail = useSupportEmail(); diff --git a/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts index 962ad7e0e2a..353d966fc74 100644 --- a/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts +++ b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts @@ -47,10 +47,14 @@ export async function handleSignUpIfMissingTransfer({ } // Pending task: route to the task within the sign-in component using - // an absolute URL built from signUpUrl. + // 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: clerk.navigate, + navigate: async to => { + await clerk.navigate(to); + }, }); }, }); From 5a674b8d38f378f1c77c94a003ff6f5b99261bea Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 8 Apr 2026 16:39:42 -0400 Subject: [PATCH 7/9] fix: Revert unnecessary addition of transferable to email link errors --- packages/shared/src/errors/emailLinkError.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/errors/emailLinkError.ts b/packages/shared/src/errors/emailLinkError.ts index a353b4da6fb..8c1055ea4a5 100644 --- a/packages/shared/src/errors/emailLinkError.ts +++ b/packages/shared/src/errors/emailLinkError.ts @@ -24,5 +24,4 @@ export const EmailLinkErrorCodeStatus = { Expired: 'expired', Failed: 'failed', ClientMismatch: 'client_mismatch', - Transferable: 'transferable', } as const; From 01bc760f333ae761a81ec657445d90bdb426edef Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 8 Apr 2026 16:47:01 -0400 Subject: [PATCH 8/9] fix: Better document the literal type for email link status --- packages/shared/src/internal/clerk-js/queryParams.ts | 11 +++++++++-- packages/ui/src/common/EmailLinkStatusCard.tsx | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) 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/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; From 41c12baf0968b7f79e67cbf09b6c673d9bc7ea60 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Thu, 9 Apr 2026 11:44:17 -0400 Subject: [PATCH 9/9] fix: import ordering --- packages/clerk-js/src/core/clerk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index c5839ea2d9e..5519718e8dd 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -137,7 +137,6 @@ import { ModuleManager } from '@/utils/moduleManager'; import { ALLOWED_PROTOCOLS, buildURL, - navigateToNextStepSignUp, createAllowedRedirectOrigins, createBeforeUnloadTracker, createPageLifecycle, @@ -150,6 +149,7 @@ import { isError, isOrganizationId, isRedirectForFAPIInitiatedFlow, + navigateToNextStepSignUp, removeClerkQueryParam, requiresUserInput, stripOrigin,