From 84d79f69fa426c7cc5bee88f8eb78a7d85272bff Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 25 Feb 2026 13:12:53 -0500 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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, From a3d6e9b1a929d4b4ebf4593afcd82b3c6161bdf2 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Thu, 9 Jul 2026 13:57:10 -0400 Subject: [PATCH 10/12] chore: Update changeset --- .changeset/fancy-candies-slide.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/fancy-candies-slide.md b/.changeset/fancy-candies-slide.md index 852bf39a8a8..51e1eafa2b8 100644 --- a/.changeset/fancy-candies-slide.md +++ b/.changeset/fancy-candies-slide.md @@ -4,4 +4,5 @@ '@clerk/ui': minor --- -Support signUpIfMissing with strict enumeration protection and Clerk component +Support sign-in-or-sign-up combined flow with Clerk component +when strict enumeration protection is enabled. From 625bdc29a709386fe895d7552ea2d2dc377c292c Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Thu, 9 Jul 2026 14:55:17 -0400 Subject: [PATCH 11/12] fix(*): Address review feedback on sign-up-if-missing transfer - Keep transferred sign-ups inside the combined flow: navigate to the nested create/... routes via relative paths and reuse the context's navigateOnSetActive instead of absolute signUpUrl-based URLs, which forced a full page reload and broke apps without a standalone SignUp route (virtual/modal routing included). - Make UserSettingsJSON.attack_protection optional and deep-normalize it in UserSettings.fromJSON so partial payloads can't crash consumers. - Derive a shared signUpIfMissingEnabled predicate in useSignInContext instead of re-deriving eligibility in three components. - Resolve the OTP card before transferring so it can't be left spinning. - Drop the redundant missingFields param from navigateToNextStepSignUp. - Extract the terminal email-link verification status check shared by the two SignIn polling loops. Co-Authored-By: Claude Fable 5 --- packages/clerk-js/src/core/clerk.ts | 4 +- .../clerk-js/src/core/resources/SignIn.ts | 16 +++- .../src/core/resources/UserSettings.ts | 10 ++- .../navigateToNextStepSignUp.test.ts | 7 -- .../clerk-js/navigateToNextStepSignUp.ts | 8 +- packages/shared/src/types/userSettings.ts | 6 +- .../SignIn/SignInFactorOneCodeForm.tsx | 33 ++++---- .../SignIn/SignInFactorOneEmailLinkCard.tsx | 13 +-- .../ui/src/components/SignIn/SignInStart.tsx | 14 +--- .../SignInFactorOneTransfer.test.tsx | 45 ++++++---- .../handleSignUpIfMissingTransfer.test.ts | 82 +++++++++---------- .../SignIn/handleSignUpIfMissingTransfer.ts | 51 +++++------- packages/ui/src/contexts/components/SignIn.ts | 13 ++- 13 files changed, 156 insertions(+), 146 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 9a4ed4fc27e..aedee9e8b1b 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2586,8 +2586,7 @@ export class Clerk implements ClerkInterface { }); case 'missing_requirements': return navigateToNextStepSignUp({ - signUp, - missingFields: res.missingFields, + signUp: res, continueSignUpUrl, verifyEmailAddressUrl, verifyPhoneNumberUrl, @@ -2650,7 +2649,6 @@ export class Clerk implements ClerkInterface { if (su.externalAccountStatus === 'verified' && su.status === 'missing_requirements') { return navigateToNextStepSignUp({ signUp, - missingFields: signUp.missingFields, continueSignUpUrl, verifyEmailAddressUrl, verifyPhoneNumberUrl, diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 0051ba9e037..b30cf186a64 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -99,6 +99,16 @@ import { import { eventBus } from '../events'; import { BaseResource, UserData, Verification } from './internal'; +/** + * Terminal states for email-link verification polling: `verified` (success), `expired` + * (link timed out), or `transferable` (`signUpIfMissing` flows — the address was verified + * but no user exists, so the caller transfers to sign-up). Shared by the legacy + * `createEmailLinkFlow` poll and `SignInFuture.waitForEmailLinkVerification` so the two + * loops can't drift apart. + */ +const isTerminalEmailLinkVerificationStatus = (status: string | null) => + status === 'verified' || status === 'expired' || status === 'transferable'; + export class SignIn extends BaseResource implements SignInResource { pathRoot = '/client/sign_ins'; @@ -333,8 +343,7 @@ export class SignIn extends BaseResource implements SignInResource { void run(() => { return this.reload() .then(res => { - const status = res[verificationKey].status; - if (status === 'verified' || status === 'expired' || status === 'transferable') { + if (isTerminalEmailLinkVerificationStatus(res[verificationKey].status)) { stop(); resolve(res); } @@ -1145,8 +1154,7 @@ class SignInFuture implements SignInFutureResource { void run(async () => { try { const res = await this.#resource.__internal_baseGet(); - const status = res.firstFactorVerification.status; - if (status === 'verified' || status === 'expired' || status === 'transferable') { + if (isTerminalEmailLinkVerificationStatus(res.firstFactorVerification.status)) { stop(); resolve(res); } diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index d45947cd51c..86c928f6d74 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -216,7 +216,15 @@ 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); + // Normalize field-by-field rather than withDefault: a present-but-partial + // attack_protection object must not leave enumeration_protection undefined. + this.attackProtection = { + enumeration_protection: { + enabled: + data.attack_protection?.enumeration_protection?.enabled ?? + this.attackProtection.enumeration_protection.enabled, + }, + }; this.enterpriseSSO = this.withDefault(data.enterprise_sso, this.enterpriseSSO); this.passkeySettings = this.withDefault(data.passkey_settings, this.passkeySettings); this.passwordSettings = data.password_settings diff --git a/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts index f4d0d95c57b..99f560a5389 100644 --- a/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts +++ b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts @@ -31,7 +31,6 @@ describe('navigateToNextStepSignUp', () => { await navigateToNextStepSignUp({ signUp, - missingFields: signUp.missingFields, ...URLS, navigate: mockNavigate, }); @@ -49,7 +48,6 @@ describe('navigateToNextStepSignUp', () => { await navigateToNextStepSignUp({ signUp, - missingFields: signUp.missingFields, ...URLS, navigate: mockNavigate, }); @@ -67,7 +65,6 @@ describe('navigateToNextStepSignUp', () => { await navigateToNextStepSignUp({ signUp, - missingFields: signUp.missingFields, ...URLS, navigate: mockNavigate, }); @@ -85,7 +82,6 @@ describe('navigateToNextStepSignUp', () => { await navigateToNextStepSignUp({ signUp, - missingFields: signUp.missingFields, ...URLS, navigate: mockNavigate, }); @@ -103,7 +99,6 @@ describe('navigateToNextStepSignUp', () => { await navigateToNextStepSignUp({ signUp, - missingFields: signUp.missingFields, ...URLS, navigate: mockNavigate, }); @@ -121,7 +116,6 @@ describe('navigateToNextStepSignUp', () => { await navigateToNextStepSignUp({ signUp, - missingFields: signUp.missingFields, ...URLS, navigate: mockNavigate, }); @@ -139,7 +133,6 @@ describe('navigateToNextStepSignUp', () => { await navigateToNextStepSignUp({ signUp, - missingFields: signUp.missingFields, ...URLS, navigate: mockNavigate, }); diff --git a/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts b/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts index 102fe2bc7e4..277b8ede011 100644 --- a/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts +++ b/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts @@ -1,9 +1,8 @@ -import type { SignUpField, SignUpResource } from '../../types'; +import type { SignUpResource } from '../../types'; import { completeSignUpFlow } from './completeSignUpFlow'; type NavigateToNextStepSignUpProps = { signUp: SignUpResource; - missingFields: SignUpField[]; continueSignUpUrl: string; verifyEmailAddressUrl: string; verifyPhoneNumberUrl: string; @@ -28,7 +27,6 @@ type NavigateToNextStepSignUpProps = { */ export const navigateToNextStepSignUp = ({ signUp, - missingFields, continueSignUpUrl, verifyEmailAddressUrl, verifyPhoneNumberUrl, @@ -38,11 +36,11 @@ export const navigateToNextStepSignUp = ({ // A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate // check must run BEFORE the generic missing-fields short-circuit below — otherwise the // callback would land on /continue instead of the challenge. - if (signUp.protectCheck || missingFields.includes('protect_check')) { + if (signUp.protectCheck || signUp.missingFields.includes('protect_check')) { return navigate(signUpProtectCheckUrl); } - if (missingFields.length) { + if (signUp.missingFields.length) { return navigate(continueSignUpUrl); } diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index 139d59f7a8a..ec5a599a2f6 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -128,7 +128,11 @@ export interface UserSettingsJSON extends ClerkResourceJSON { password_settings: PasswordSettingsData; passkey_settings: PasskeySettingsData; username_settings: UsernameSettingsData; - attack_protection: AttackProtectionData; + /** + * Optional because older environment payloads (and existing mocks) predate the field. + * `UserSettings.fromJSON` falls back to enumeration protection disabled. + */ + attack_protection?: AttackProtectionData; } export interface UserSettingsResource extends ClerkResource { diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index ff52d32fa0e..e18f44c00e9 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -9,7 +9,7 @@ import type { VerificationCodeCardProps } from '@/ui/elements/VerificationCodeCa import { VerificationCodeCard } from '@/ui/elements/VerificationCodeCard'; import { handleError } from '@/ui/utils/errorHandler'; -import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; +import { useCoreSignIn, useSignInContext } from '../../contexts'; import { useFetch } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { type LocalizationKey } from '../../localization'; @@ -39,9 +39,8 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => const card = useCardState(); const { navigate } = useRouter(); const ctx = useSignInContext(); - const { afterSignInUrl, afterSignUpUrl, signUpUrl, isCombinedFlow, navigateOnSetActive } = ctx; + const { afterSignInUrl, afterSignUpUrl, signUpIfMissingEnabled, navigateOnSetActive } = ctx; const { setActive } = useClerk(); - const { userSettings } = useEnvironment(); const supportEmail = useSupportEmail(); const clerk = useClerk(); @@ -144,18 +143,22 @@ 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); + if (signUpIfMissingEnabled && signIn.firstFactorVerification.status === 'transferable') { + // The code itself was correct (`transferable` = verified, but no matching user), so + // mirror the success path above: resolve the OTP card, then navigate. Resolving also + // guarantees the card doesn't sit in a loading state forever if the transferred + // sign-up requires no further routing. + return resolve() + .then(() => + handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + navigateOnSetActive, + 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 94a5ebc9048..258312ad481 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -9,7 +9,7 @@ import { handleError } from '@/ui/utils/errorHandler'; import { EmailLinkStatusCard } from '../../common'; import { buildVerificationRedirectUrl } from '../../common/redirects'; -import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; +import { useCoreSignIn, useSignInContext } from '../../contexts'; import { Flow, localizationKeys, useLocalizations } from '../../customizables'; import { useCardState } from '../../elements/contexts'; import { useEmailLink } from '../../hooks/useEmailLink'; @@ -28,10 +28,9 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const card = useCardState(); const signIn = useCoreSignIn(); const signInContext = useSignInContext(); - const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl, isCombinedFlow } = signInContext; + const { signInUrl, afterSignInUrl, afterSignUpUrl, signUpIfMissingEnabled, navigateOnSetActive } = signInContext; const { navigate } = useRouter(); const { setActive } = useClerk(); - const { userSettings } = useEnvironment(); const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn); const [showVerifyModal, setShowVerifyModal] = React.useState(false); const clerk = useClerk(); @@ -65,16 +64,12 @@ 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' - ) { + } else if (signUpIfMissingEnabled && ver.status === 'transferable') { return handleSignUpIfMissingTransfer({ clerk, navigate, afterSignUpUrl, - signUpUrl, + navigateOnSetActive, unsafeMetadata: signInContext.unsafeMetadata, }); } else if (ver.verifiedFromTheSameClient()) { diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index 6a77d1e1cfc..aebd94f0b34 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -90,7 +90,7 @@ function SignInStartInternal(): JSX.Element { const signIn = useCoreSignIn(); const { navigate } = useRouter(); const ctx = useSignInContext(); - const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx; + const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, signUpIfMissingEnabled, navigateOnSetActive } = ctx; const supportEmail = useSupportEmail(); const totalEnabledAuthMethods = useTotalEnabledAuthMethods(); const identifierAttributes = useMemo( @@ -388,17 +388,11 @@ function SignInStartInternal(): JSX.Element { } as any); } try { - // 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. + // On top of the context-level preconditions, sign-up-if-missing only + // supports 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 shouldSignUpIfMissing = signUpIfMissingEnabled && signUpAttribute !== 'username' && !hasPassword; const res = await safePasswordSignInForEnterpriseSSOInstance( signIn.create({ diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx index 464200d05ee..b2930bb48e2 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx @@ -39,13 +39,17 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => { 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, - }), - ); - }); + // The transfer runs after the OTP card's ~750ms success animation resolves. + await waitFor( + () => { + expect(fixtures.signUp.create).toHaveBeenCalledWith( + expect.objectContaining({ + transfer: true, + }), + ); + }, + { timeout: 3000 }, + ); }); it('navigates to create/continue when transfer results in missing_requirements', async () => { @@ -76,9 +80,13 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => { const { userEvent } = render(, { wrapper }); await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); - await waitFor(() => { - expect(fixtures.router.navigate).toHaveBeenCalledWith(expect.stringContaining('continue')); - }); + // Relative path keeps the transferred sign-up inside the combined flow. + await waitFor( + () => { + expect(fixtures.router.navigate).toHaveBeenCalledWith('../create/continue'); + }, + { timeout: 3000 }, + ); }); it('does not trigger transfer when enumeration protection is disabled', async () => { @@ -197,7 +205,7 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => { transfer: true, }), ); - expect(fixtures.router.navigate).toHaveBeenCalledWith(expect.stringContaining('continue')); + expect(fixtures.router.navigate).toHaveBeenCalledWith('../create/continue'); }); }); @@ -227,10 +235,15 @@ describe('SignInFactorOne sign-up-if-missing transfer', () => { await userEvent.type(input, '123456'); - await waitFor(() => { - expect(fixtures.signUp.create).toHaveBeenCalled(); - expect(input).toHaveValue(''); - expect(input).not.toBeDisabled(); - }); + // Success animation (~750ms) precedes the transfer, and the error feedback + // resets the input after another ~750ms. + await waitFor( + () => { + expect(fixtures.signUp.create).toHaveBeenCalled(); + expect(input).toHaveValue(''); + expect(input).not.toBeDisabled(); + }, + { timeout: 5000 }, + ); }); }); diff --git a/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts index 025136e5f06..1072b78b499 100644 --- a/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts @@ -4,6 +4,7 @@ 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 { @@ -31,7 +32,7 @@ describe('handleSignUpIfMissingTransfer', () => { clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - signUpUrl: 'https://test.com/sign-up', + navigateOnSetActive: mockNavigateOnSetActive, }); expect(clerk.client.signUp.create).toHaveBeenCalledWith({ @@ -48,7 +49,7 @@ describe('handleSignUpIfMissingTransfer', () => { clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - signUpUrl: 'https://test.com/sign-up', + navigateOnSetActive: mockNavigateOnSetActive, unsafeMetadata, }); @@ -65,7 +66,7 @@ describe('handleSignUpIfMissingTransfer', () => { clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - signUpUrl: 'https://test.com/sign-up', + navigateOnSetActive: mockNavigateOnSetActive, }); expect(clerk.setActive).toHaveBeenCalledWith( @@ -76,112 +77,105 @@ describe('handleSignUpIfMissingTransfer', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); - it('uses clerk.navigate with the decorated afterSignUpUrl when the session has no pending task', async () => { + it('delegates post-setActive navigation to navigateOnSetActive with afterSignUpUrl', async () => { const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }) as LoadedClerk & { - navigate: ReturnType; setActive: ReturnType; }; - const decorateUrl = vi.fn((url: string) => `${url}?decorated`); + const session = { currentTask: null } as any; + const decorateUrl = (url: string) => url; clerk.setActive.mockImplementation(async params => { - await params.navigate({ - session: { currentTask: null } as any, - decorateUrl, - }); + await params.navigate({ session, decorateUrl }); }); await handleSignUpIfMissingTransfer({ clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - signUpUrl: 'https://test.com/sign-up', + navigateOnSetActive: mockNavigateOnSetActive, }); - expect(decorateUrl).toHaveBeenCalledWith('https://test.com'); - expect(clerk.navigate).toHaveBeenCalledWith('https://test.com?decorated'); + expect(mockNavigateOnSetActive).toHaveBeenCalledWith({ + session, + redirectUrl: 'https://test.com', + decorateUrl, + }); }); - 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, - }); + it('routes to the combined-flow 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', + navigateOnSetActive: mockNavigateOnSetActive, }); - // 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'); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0] as string).toBe('../create/continue'); + expect(clerk.setActive).not.toHaveBeenCalled(); }); - it('routes to the continue page when sign-up has missing fields', async () => { + it('routes to verify-email-address when sign-up has unverified email and no missing fields', async () => { const clerk = createMockClerk({ status: 'missing_requirements', - missingFields: ['first_name'], - unverifiedFields: [], + missingFields: [], + unverifiedFields: ['email_address'], }); await handleSignUpIfMissingTransfer({ clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - signUpUrl: 'https://test.com/sign-up', + navigateOnSetActive: mockNavigateOnSetActive, }); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate.mock.calls[0][0] as string).toContain('/continue'); + expect(mockNavigate.mock.calls[0][0] as string).toBe('../create/verify-email-address'); expect(clerk.setActive).not.toHaveBeenCalled(); }); - it('routes to verify-email-address when sign-up has unverified email and no missing fields', async () => { + 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: ['email_address'], + unverifiedFields: ['phone_number'], }); await handleSignUpIfMissingTransfer({ clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - signUpUrl: 'https://test.com/sign-up', + navigateOnSetActive: mockNavigateOnSetActive, }); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate.mock.calls[0][0] as string).toContain('/verify-email-address'); + expect(mockNavigate.mock.calls[0][0] as string).toBe('../create/verify-phone-number'); expect(clerk.setActive).not.toHaveBeenCalled(); }); - it('routes to verify-phone-number when sign-up has unverified phone and no missing fields', async () => { + it('routes to protect-check when the sign-up is protect-gated', async () => { const clerk = createMockClerk({ status: 'missing_requirements', - missingFields: [], - unverifiedFields: ['phone_number'], + missingFields: ['protect_check', 'first_name'], + unverifiedFields: [], }); await handleSignUpIfMissingTransfer({ clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - signUpUrl: 'https://test.com/sign-up', + navigateOnSetActive: mockNavigateOnSetActive, }); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate.mock.calls[0][0] as string).toContain('/verify-phone-number'); + expect(mockNavigate.mock.calls[0][0] as string).toBe('../create/protect-check'); expect(clerk.setActive).not.toHaveBeenCalled(); }); @@ -193,7 +187,7 @@ describe('handleSignUpIfMissingTransfer', () => { clerk, navigate: mockNavigate, afterSignUpUrl: 'https://test.com', - signUpUrl: 'https://test.com/sign-up', + 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 index 772b02342ee..e22626c08b2 100644 --- a/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts +++ b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts @@ -1,16 +1,15 @@ 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 { SignInContextType } from '../../contexts'; import type { RouteContextValue } from '../../router/RouteContext'; type HandleSignUpIfMissingTransferProps = { clerk: LoadedClerk; navigate: RouteContextValue['navigate']; afterSignUpUrl: string; - signUpUrl: string; + navigateOnSetActive: SignInContextType['navigateOnSetActive']; unsafeMetadata?: SignUpUnsafeMetadata; }; @@ -19,13 +18,21 @@ type HandleSignUpIfMissingTransferProps = { * `firstFactorVerification.status === 'transferable'` (i.e. the user does not * exist and `signUpIfMissing` was used). * - * This mirrors the OAuth transfer handling in `_handleRedirectCallback`. + * This mirrors the OAuth transfer handling in `_handleRedirectCallback`, but + * navigates with paths relative to the combined `` flow so + * the transferred sign-up stays inside the mounted component (an absolute + * `signUpUrl`-based URL would leave the component and trigger a full page + * reload, or break apps without a standalone SignUp route). + * + * `navigate` must come from a route mounted directly under the SignIn root + * (e.g. `factor-one`), so the sign-up screens nested at `create/...` resolve + * as `../create/...`. */ export async function handleSignUpIfMissingTransfer({ clerk, navigate, afterSignUpUrl, - signUpUrl, + navigateOnSetActive, unsafeMetadata, }: HandleSignUpIfMissingTransferProps): Promise { const res = await clerk.client.signUp.create({ @@ -38,38 +45,22 @@ export async function handleSignUpIfMissingTransfer({ 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); - }, - }); + // navigateOnSetActive routes pending session tasks to the combined + // flow's `create/...` task routes and handles Safari ITP via decorateUrl. + await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl }); }, }); case 'missing_requirements': // Same routing logic as the OAuth transfer flow: if the sign-up is - // protect-gated go to /protect-check; if there are missing fields go to - // /continue; otherwise let completeSignUpFlow route any unverified + // protect-gated go to protect-check; 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 }), - signUpProtectCheckUrl: buildURL({ base: signUpUrl, hashPath: '/protect-check' }, { stringify: true }), + continueSignUpUrl: '../create/continue', + verifyEmailAddressUrl: '../create/verify-email-address', + verifyPhoneNumberUrl: '../create/verify-phone-number', + signUpProtectCheckUrl: '../create/protect-check', navigate, }); default: diff --git a/packages/ui/src/contexts/components/SignIn.ts b/packages/ui/src/contexts/components/SignIn.ts index 6337fcb5d32..f399a1876b8 100644 --- a/packages/ui/src/contexts/components/SignIn.ts +++ b/packages/ui/src/contexts/components/SignIn.ts @@ -1,4 +1,4 @@ -import { SIGN_IN_INITIAL_VALUE_KEYS } from '@clerk/shared/internal/clerk-js/constants'; +import { SIGN_IN_INITIAL_VALUE_KEYS, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants'; import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { buildURL } from '@clerk/shared/internal/clerk-js/url'; @@ -30,6 +30,7 @@ export type SignInContextType = Omit { ); } + // Static preconditions of the sign-up-if-missing flow, shared by SignInStart (which requests + // `signUpIfMissing` on sign-in create) and the factor-one cards (which handle the resulting + // `transferable` verification status). Per-attempt conditions (identifier type, password use) + // stay at the call sites. + const signUpIfMissingEnabled = + isCombinedFlow && + userSettings.attackProtection.enumeration_protection.enabled && + signUpMode === SIGN_UP_MODES.PUBLIC; + const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); // Built off `signUpUrl`, which is rewritten to `#/create` in the combined flow, so this // resolves to the embedded `…/create/protect-check` route there and the standalone sign-up route @@ -197,6 +207,7 @@ export const useSignInContext = (): SignInContextType => { initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString, isCombinedFlow, + signUpIfMissingEnabled, navigateOnSetActive, taskUrl, }; From 77325fdcc59a7241a2ae17dd883afe184eb8ca7f Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Thu, 9 Jul 2026 17:31:36 -0400 Subject: [PATCH 12/12] fix(ui): Distinct copy for transferable email-link verification The link-click tab of a signUpIfMissing email-link flow previously showed 'Successfully signed in', but the user is not signed in yet - the email was verified and the original tab continues the flow as a sign-up. Render a dedicated 'Email verified' card (new signIn.emailLink.verifiedTransferable localization keys) driven by the __clerk_status=transferable query param. Co-Authored-By: Claude Fable 5 --- .changeset/fancy-candies-slide.md | 1 + packages/localizations/src/ar-SA.ts | 4 ++ packages/localizations/src/be-BY.ts | 4 ++ packages/localizations/src/bg-BG.ts | 4 ++ packages/localizations/src/bn-IN.ts | 4 ++ packages/localizations/src/ca-ES.ts | 4 ++ packages/localizations/src/cs-CZ.ts | 4 ++ packages/localizations/src/da-DK.ts | 4 ++ packages/localizations/src/de-DE.ts | 4 ++ packages/localizations/src/el-GR.ts | 4 ++ packages/localizations/src/en-GB.ts | 4 ++ packages/localizations/src/en-US.ts | 4 ++ packages/localizations/src/es-CR.ts | 4 ++ packages/localizations/src/es-ES.ts | 4 ++ packages/localizations/src/es-MX.ts | 4 ++ packages/localizations/src/es-UY.ts | 4 ++ packages/localizations/src/fa-IR.ts | 4 ++ packages/localizations/src/fi-FI.ts | 4 ++ packages/localizations/src/fr-FR.ts | 4 ++ packages/localizations/src/he-IL.ts | 4 ++ packages/localizations/src/hi-IN.ts | 4 ++ packages/localizations/src/hr-HR.ts | 4 ++ packages/localizations/src/hu-HU.ts | 4 ++ packages/localizations/src/id-ID.ts | 4 ++ packages/localizations/src/is-IS.ts | 4 ++ packages/localizations/src/it-IT.ts | 4 ++ packages/localizations/src/ja-JP.ts | 4 ++ packages/localizations/src/kk-KZ.ts | 4 ++ packages/localizations/src/ko-KR.ts | 4 ++ packages/localizations/src/mn-MN.ts | 4 ++ packages/localizations/src/ms-MY.ts | 4 ++ packages/localizations/src/nb-NO.ts | 4 ++ packages/localizations/src/nl-BE.ts | 4 ++ packages/localizations/src/nl-NL.ts | 4 ++ packages/localizations/src/pl-PL.ts | 4 ++ packages/localizations/src/pt-BR.ts | 4 ++ packages/localizations/src/pt-PT.ts | 4 ++ packages/localizations/src/ro-RO.ts | 4 ++ packages/localizations/src/ru-RU.ts | 4 ++ packages/localizations/src/sk-SK.ts | 4 ++ packages/localizations/src/sr-RS.ts | 4 ++ packages/localizations/src/sv-SE.ts | 4 ++ packages/localizations/src/ta-IN.ts | 4 ++ packages/localizations/src/te-IN.ts | 4 ++ packages/localizations/src/th-TH.ts | 4 ++ packages/localizations/src/tr-TR.ts | 4 ++ packages/localizations/src/uk-UA.ts | 4 ++ packages/localizations/src/vi-VN.ts | 4 ++ packages/localizations/src/zh-CN.ts | 4 ++ packages/localizations/src/zh-TW.ts | 4 ++ .../src/internal/clerk-js/queryParams.ts | 8 ++-- packages/shared/src/types/localization.ts | 8 ++++ .../src/common/EmailLinkCompleteFlowCard.tsx | 6 +++ .../ui/src/common/EmailLinkStatusCard.tsx | 10 +++-- packages/ui/src/common/EmailLinkVerify.tsx | 10 +++++ .../SignInEmailLinkFlowComplete.test.tsx | 42 +++++++++++++++++++ 56 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInEmailLinkFlowComplete.test.tsx diff --git a/.changeset/fancy-candies-slide.md b/.changeset/fancy-candies-slide.md index 51e1eafa2b8..75e710973a8 100644 --- a/.changeset/fancy-candies-slide.md +++ b/.changeset/fancy-candies-slide.md @@ -2,6 +2,7 @@ '@clerk/clerk-js': minor '@clerk/shared': minor '@clerk/ui': minor +'@clerk/localizations': minor --- Support sign-in-or-sign-up combined flow with Clerk component diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index 9b70f8d0a2d..ad1759151ec 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -1301,6 +1301,10 @@ export const arSA: LocalizationResource = { subtitleNewTab: 'ارجع إلى علامة التبويب المفتوحة حديثًا للمتابعة', titleNewTab: 'تم تسجيل الدخول في علامة تبويب أخرى', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'استخدم رابط التحقق المرسل إلى بريدك الإلكتروني', diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index 3fb72ae5d54..7df1d9f349e 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -1309,6 +1309,10 @@ export const beBY: LocalizationResource = { subtitleNewTab: 'Верніцеся на толькі што адчыненую ўкладку, каб працягнуць', titleNewTab: 'Залогіньцеся на іншай укладцы', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Выкарыстоўвайце спасылку для пацвярджэння, адпраўленую на вашу электронную пошту', diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index 4e14def42ab..9f5c22070ed 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -1305,6 +1305,10 @@ export const bgBG: LocalizationResource = { subtitleNewTab: 'Върнете се в новоотворения таб, за да продължите', titleNewTab: 'Влезнали сте в друг таб', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Използвайте връзката за потвърждение, изпратена на вашия имейл', diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index d8a33bdfa92..e21cbba3c3f 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -1313,6 +1313,10 @@ export const bnIN: LocalizationResource = { subtitleNewTab: 'চালিয়ে যেতে নতুন খোলা ট্যাবে ফিরে যান', titleNewTab: 'অন্য ট্যাবে সাইন ইন হয়েছে', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'আপনার ইমেইলে পাঠানো যাচাইকরণ লিঙ্কটি ব্যবহার করুন', diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index fe24667d6a5..96d6faa2041 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -1313,6 +1313,10 @@ export const caES: LocalizationResource = { subtitleNewTab: 'Torna a la pestanya recentment oberta per continuar', titleNewTab: "S'ha iniciat sessió en una altra pestanya", }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: "Utilitzeu l'enllaç de verificació enviat al vostre correu electrònic", diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index 40ca97e4f47..0c54090da53 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -1312,6 +1312,10 @@ export const csCZ: LocalizationResource = { subtitleNewTab: 'Vraťte se na nově otevřenou kartu pro pokračování', titleNewTab: 'Přihlášeno na jiné kartě', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Použijte ověřovací odkaz zaslaný na váš e-mail', diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 8f6cd90e1ce..6e30968cf92 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -1303,6 +1303,10 @@ export const daDK: LocalizationResource = { subtitleNewTab: 'Vend tilbage til den nyligt åbnede fane for at fortsætte', titleNewTab: 'Logget ind på anden fane', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Brug bekræftelseslinket, der er sendt til din e-mail', diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index f7c181cf898..029794c7aa8 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -1320,6 +1320,10 @@ export const deDE: LocalizationResource = { subtitleNewTab: 'Kehren Sie zum neu geöffneten Tab zurück, um fortzufahren', titleNewTab: 'In einem anderen Tab angemeldet', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Verwenden Sie den an Ihre E-Mail gesendeten Bestätigungslink', diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index fbc71c2473a..e1f2f078d9e 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -1313,6 +1313,10 @@ export const elGR: LocalizationResource = { subtitleNewTab: 'Επιστροφή στη νέα καρτέλα που άνοιξε για να συνεχίσετε', titleNewTab: 'Έχετε συνδεθεί σε άλλη καρτέλα', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Χρησιμοποιήστε τον σύνδεσμο επαλήθευσης που στάλθηκε στο email σας', diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index 74fa7ea9e73..7f84cd3606c 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -1305,6 +1305,10 @@ export const enGB: LocalizationResource = { subtitleNewTab: 'Return to the newly opened tab to continue', titleNewTab: 'Signed in on other tab', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Use the verification link sent to your email', diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index d766d4ce383..59f9efe4501 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1333,6 +1333,10 @@ export const enUS: LocalizationResource = { subtitleNewTab: 'Return to the newly opened tab to continue', titleNewTab: 'Signed in on other tab', }, + verifiedTransferable: { + subtitle: 'Return to original tab to continue', + title: 'Email verified', + }, }, emailLinkMfa: { formSubtitle: 'Use the verification link sent to your email', diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index 6c465f680a0..22ee38d25d0 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -1310,6 +1310,10 @@ export const esCR: LocalizationResource = { subtitleNewTab: 'Regresa a la pestaña recién abierta para continuar', titleNewTab: 'Sesión iniciada en otra pestaña', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utiliza el enlace de verificación enviado a tu correo electrónico', diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index 2d6895e5822..3be2a09acfe 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -1314,6 +1314,10 @@ export const esES: LocalizationResource = { subtitleNewTab: 'Regrese a la pestaña recién abierta para continuar', titleNewTab: 'Inició sesión en otra pestaña', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utiliza el enlace de verificación enviado a tu correo electrónico', diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index 64b90aa3da2..ee17de2eb47 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -1311,6 +1311,10 @@ export const esMX: LocalizationResource = { subtitleNewTab: 'Regresa a la pestaña recién abierta para continuar', titleNewTab: 'Sesión iniciada en otra pestaña', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utiliza el enlace de verificación enviado a tu correo electrónico', diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index b027e07069f..b7861f2cdb1 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -1309,6 +1309,10 @@ export const esUY: LocalizationResource = { subtitleNewTab: 'Volvé a la nueva pestaña para continuar', titleNewTab: 'Sesión iniciada en otra pestaña', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utiliza el enlace de verificación enviado a tu correo electrónico', diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index 1cf0c875abf..eccb0f58f7d 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -1314,6 +1314,10 @@ export const faIR: LocalizationResource = { subtitleNewTab: 'برای ادامه به برگه تازه باز شده برگردید', titleNewTab: 'در برگه دیگر وارد سیستم شده‌اید', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'از لینک تأیید ارسال شده به ایمیل خود استفاده کنید', diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index d02b810a53a..9d14bc476ef 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -1315,6 +1315,10 @@ export const fiFI: LocalizationResource = { subtitleNewTab: 'Palaa uuteen välilehteen jatkaaksesi', titleNewTab: 'Kirjautunut toiseen välilehteen', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Käytä sähköpostiisi lähetettyä vahvistuslinkkiä', diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index 3ef7465f900..c1312d1ebfb 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -1321,6 +1321,10 @@ export const frFR: LocalizationResource = { subtitleNewTab: "Revenez à l'onglet nouvellement ouvert pour continuer", titleNewTab: 'Connecté sur un autre onglet', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utilisez le lien de vérification envoyé par e-mail', diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index b1763ec3337..d2b27020b11 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -1297,6 +1297,10 @@ export const heIL: LocalizationResource = { subtitleNewTab: 'חזור לכרטיסייה שנפתחה חדשה להמשך', titleNewTab: 'נכנס בכרטיסייה אחרת', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'השתמש בקישור האימות שנשלח לדוא״ל שלך', diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index 98812de0410..24a1017fdf6 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -1313,6 +1313,10 @@ export const hiIN: LocalizationResource = { subtitleNewTab: 'जारी रखने के लिए नए खोले गए टैब पर वापस जाएं', titleNewTab: 'दूसरे टैब पर साइन इन हो गया', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'अपने ईमेल पर भेजे गए सत्यापन लिंक का उपयोग करें', diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index db93f008960..bbcbf352c5e 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -1315,6 +1315,10 @@ export const hrHR: LocalizationResource = { subtitleNewTab: 'Vratite se na novootvorenu karticu za nastavak', titleNewTab: 'Prijavljeni na drugoj kartici', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Koristite vezu za provjeru poslanu na vašu e-poštu', diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index 3a3aff4cfe8..c2d58cf45ef 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -1317,6 +1317,10 @@ export const huHU: LocalizationResource = { subtitleNewTab: 'Menj át az újonan megnyitott lapra a folytatáshoz', titleNewTab: 'Egy másik lapon bejelezkeztél be', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Használja az e-mail címére küldött megerősítő linket', diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index df03c826a49..d3612fad0b9 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -1308,6 +1308,10 @@ export const idID: LocalizationResource = { subtitleNewTab: 'Kembali ke tab yang baru dibuka untuk melanjutkan', titleNewTab: 'Masuk di tab lain', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Gunakan tautan verifikasi yang dikirim ke email Anda', diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index a2f5087a28e..e8ee2481968 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -1316,6 +1316,10 @@ export const isIS: LocalizationResource = { subtitleNewTab: 'Farðu aftur í nýopnaða flipann til að halda áfram', titleNewTab: 'Skráður inn á öðrum flipa', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Notaðu staðfestingartengilinn sem sendur var á tölvupóstinn þinn', diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index 305b7cc39d0..67e633ef2ba 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -1313,6 +1313,10 @@ export const itIT: LocalizationResource = { subtitleNewTab: 'Ritorna sulla nuova scheda aperta per continuare', titleNewTab: "Accedi da un'altra scheda", }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Usa il link di verifica inviato alla tua email', diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index ad9f53c787f..800890eb4f8 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -1314,6 +1314,10 @@ export const jaJP: LocalizationResource = { subtitleNewTab: '新しく開いたタブに戻って続行してください', titleNewTab: '他のタブでサインイン済み', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'メールに送信された確認リンクを使用してください', diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index f2ecf7e21fe..9a3e5fe2c31 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -1296,6 +1296,10 @@ export const kkKZ: LocalizationResource = { subtitleNewTab: 'Жалғастыру үшін жаңа бетке оралыңыз', titleNewTab: 'Басқа бетте кірдіңіз', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Электрондық поштаңызға жіберілген растау сілтемесін пайдаланыңыз', diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index 0bb35f7cee4..dea5f8babdb 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -1301,6 +1301,10 @@ export const koKR: LocalizationResource = { subtitleNewTab: '계속하려면 새로 연 탭으로 돌아가세요', titleNewTab: '다른 탭에서 로그인', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: '이메일로 전송된 확인 링크를 사용하세요', diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index 0d25f3cea12..4ef8c5d61ee 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -1306,6 +1306,10 @@ export const mnMN: LocalizationResource = { subtitleNewTab: 'Үргэлжлүүлэхийн тулд шинээр нээгдсэн таб руу буцна уу', titleNewTab: 'Өөр таб дээр нэвтэрсэн', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Имэйлдээ илгээсэн баталгаажуулах холбоосыг ашиглана уу', diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index 72e32d5764c..fde3941a258 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -1318,6 +1318,10 @@ export const msMY: LocalizationResource = { subtitleNewTab: 'Kembali ke tab yang baru dibuka untuk meneruskan', titleNewTab: 'Didaftarkan masuk pada tab lain', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Gunakan pautan pengesahan yang dihantar ke e-mel anda', diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index edd764c32d5..2210279ee2b 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -1316,6 +1316,10 @@ export const nbNO: LocalizationResource = { subtitleNewTab: 'Gå tilbake til den nyåpnede fanen for å fortsette', titleNewTab: 'Logget inn på en annen fane', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Bruk bekreftelseslenken som ble sendt til din e-post', diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index 4107509c7d1..62f044935f2 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -1306,6 +1306,10 @@ export const nlBE: LocalizationResource = { subtitleNewTab: 'Ga naar de pasgeopende tab om verder te gaan', titleNewTab: 'Ingelogd in andere tab', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Gebruik de verificatielink die naar je e-mail is verzonden', diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index b54ddc59780..c8ab21c5edb 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -1306,6 +1306,10 @@ export const nlNL: LocalizationResource = { subtitleNewTab: 'Ga naar de pasgeopende tab om verder te gaan', titleNewTab: 'Ingelogd in andere tab', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Gebruik de verificatielink die naar je e-mail is verzonden', diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index 92adfd113a1..e9c0cb5c1ad 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -1306,6 +1306,10 @@ export const plPL: LocalizationResource = { subtitleNewTab: 'Powróć do nowo otwartej karty, aby kontynuować', titleNewTab: 'Zalogowano na innej karcie', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Użyj linku weryfikacyjnego wysłanego na Twój e-mail', diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index 0b9478c97dd..6fe7518d0e5 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -1315,6 +1315,10 @@ export const ptBR: LocalizationResource = { subtitleNewTab: 'Retorne para a nova aba que foi aberta para continuar', titleNewTab: 'Conectado em outra aba', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Use o link de verificação enviado para o seu e-mail', diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index f378392dcb4..2e357d87f19 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -1316,6 +1316,10 @@ export const ptPT: LocalizationResource = { subtitleNewTab: 'Regresse ao novo separador que foi aberto para continuar', titleNewTab: 'Sessão iniciada noutro separador', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Utilize a ligação de verificação enviada para o seu e-mail', diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index 6a91ac6bbd0..f99d6ccbd43 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -1317,6 +1317,10 @@ export const roRO: LocalizationResource = { subtitleNewTab: 'Revino în noua filă deschisă pentru a continua', titleNewTab: 'Autentificat în altă filă', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Folosiți linkul de verificare trimis la adresa dvs. de e-mail', diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 545061ad0e5..ff2ce3d3165 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -1313,6 +1313,10 @@ export const ruRU: LocalizationResource = { subtitleNewTab: 'Вернитесь на только что открытую вкладку, чтобы продолжить', titleNewTab: 'Залогиньтесь на другой вкладке', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Используйте ссылку для подтверждения, отправленную на вашу электронную почту', diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index 0c34697e47f..074c5b7c6ca 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -1306,6 +1306,10 @@ export const skSK: LocalizationResource = { subtitleNewTab: 'Vráťte sa do novootvoreného okna pre pokračovanie', titleNewTab: 'Prihlásené v inom okne', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Použite overovací odkaz odoslaný na váš e-mail', diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index 92aee810ba2..cef7e45938e 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -1303,6 +1303,10 @@ export const srRS: LocalizationResource = { subtitleNewTab: 'Vrati se na novootvoreni tab da nastaviš', titleNewTab: 'Prijavljen na drugom tabu', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Користите везу за верификацију послату на вашу е-пошту', diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index 56d3da38257..2c3883cef6f 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -1306,6 +1306,10 @@ export const svSE: LocalizationResource = { subtitleNewTab: 'Återgå till den nyligen öppnade fliken för att fortsätta', titleNewTab: 'Loggade in på annan flik', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Använd verifieringslänken som skickades till din e-post', diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index 587a71d59e5..ef425b989e5 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -1319,6 +1319,10 @@ export const taIN: LocalizationResource = { subtitleNewTab: 'தொடர புதிதாகத் திறக்கப்பட்ட தாவலுக்குத் திரும்பவும்', titleNewTab: 'மற்ற தாவலில் உள்நுழைந்தது', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'உங்கள் மின்னஞ்சலுக்கு அனுப்பப்பட்ட சரிபார்ப்பு இணைப்பைப் பயன்படுத்தவும்', diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index de16deaba50..06189919e93 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -1316,6 +1316,10 @@ export const teIN: LocalizationResource = { subtitleNewTab: 'కొనసాగించడానికి కొత్తగా తెరిచిన ట్యాబ్‌కి తిరిగి వెళ్ళండి', titleNewTab: 'ఇతర ట్యాబ్‌లో సైన్ ఇన్ చేశారు', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'మీ ఇమెయిల్‌కు పంపబడిన ధృవీకరణ లింక్‌ను ఉపయోగించండి', diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index 8c8b34e16ba..38b6ce83ac1 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -1305,6 +1305,10 @@ export const thTH: LocalizationResource = { subtitleNewTab: 'กลับไปยังแท็บที่เปิดใหม่เพื่อดำเนินการต่อ', titleNewTab: 'เข้าสู่ระบบในแท็บอื่น', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'ใช้ลิงก์ยืนยันที่ส่งไปยังอีเมลของคุณ', diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index 98e08e93e4a..732359efa17 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -1305,6 +1305,10 @@ export const trTR: LocalizationResource = { subtitleNewTab: 'Devam etmek için yeni açılmış sekmeye dönün', titleNewTab: 'Farklı bir sekmede giriş yapıldı', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'E-postanıza gönderilen doğrulama bağlantısını kullanın', diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index dc6f645e1c5..f78bf4c7161 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -1303,6 +1303,10 @@ export const ukUA: LocalizationResource = { subtitleNewTab: 'Поверніться до щойно відкритої вкладки, щоб продовжити', titleNewTab: 'Ви ввійшли на іншій вкладці', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Використовуйте посилання для підтвердження, надіслане на вашу електронну пошту', diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index 4d61f937845..99f226f3ed8 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -1313,6 +1313,10 @@ export const viVN: LocalizationResource = { subtitleNewTab: 'Quay lại tab mới được mở để tiếp tục', titleNewTab: 'Đăng nhập trên tab khác', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: 'Sử dụng liên kết xác minh được gửi đến email của bạn', diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index 50fd9254b16..c8ef6f5a50d 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -1293,6 +1293,10 @@ export const zhCN: LocalizationResource = { subtitleNewTab: '返回新打开的标签页继续', titleNewTab: '在其他标签页上登录', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: '使用发送到您电子邮件的验证链接', diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index b79689fb355..fc1c7c0ac61 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -1296,6 +1296,10 @@ export const zhTW: LocalizationResource = { subtitleNewTab: '返回新開啟的分頁以繼續', titleNewTab: '已在其他分頁登入', }, + verifiedTransferable: { + subtitle: undefined, + title: undefined, + }, }, emailLinkMfa: { formSubtitle: '使用發送到您電子郵件的驗證連結', diff --git a/packages/shared/src/internal/clerk-js/queryParams.ts b/packages/shared/src/internal/clerk-js/queryParams.ts index d9466b843ea..6d16482231b 100644 --- a/packages/shared/src/internal/clerk-js/queryParams.ts +++ b/packages/shared/src/internal/clerk-js/queryParams.ts @@ -21,10 +21,10 @@ type ClerkQueryParam = (typeof _ClerkQueryParams)[number]; /** * 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`. + * endpoint. `transferable` belongs to the `signUpIfMissing` flow - the + * verification succeeded but the user does not exist, so the originating + * (polling) tab performs a sign-up transfer while the link-click tab renders + * a "return to the original tab" card; see `EmailLinkUIStatus`. */ export type VerifyTokenStatus = | 'verified' diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 9ca7ecee35e..2e2f5d0c933 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -510,6 +510,14 @@ export type __internal_LocalizationResource = { titleNewTab: LocalizationValue; subtitleNewTab: LocalizationValue; }; + /** + * Shown in the link-click tab when the verified email has no matching user + * and the original tab transfers the flow to sign-up (`signUpIfMissing`). + */ + verifiedTransferable: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; loading: { title: LocalizationValue; subtitle: LocalizationValue; diff --git a/packages/ui/src/common/EmailLinkCompleteFlowCard.tsx b/packages/ui/src/common/EmailLinkCompleteFlowCard.tsx index dbede6a4b5f..5612338a826 100644 --- a/packages/ui/src/common/EmailLinkCompleteFlowCard.tsx +++ b/packages/ui/src/common/EmailLinkCompleteFlowCard.tsx @@ -12,6 +12,12 @@ const signInLocalizationKeys = { title: localizationKeys('signIn.emailLink.verified.title'), subtitle: localizationKeys('signIn.emailLink.verifiedSwitchTab.subtitle'), }, + // signUpIfMissing transfer: the email verified but no user exists yet, so + // "Successfully signed in" would be wrong - the original tab continues as a sign-up. + transferable: { + title: localizationKeys('signIn.emailLink.verifiedTransferable.title'), + subtitle: localizationKeys('signIn.emailLink.verifiedTransferable.subtitle'), + }, loading: { title: localizationKeys('signIn.emailLink.loading.title'), subtitle: localizationKeys('signIn.emailLink.loading.subtitle'), diff --git a/packages/ui/src/common/EmailLinkStatusCard.tsx b/packages/ui/src/common/EmailLinkStatusCard.tsx index a862a6ddce9..611858279f0 100644 --- a/packages/ui/src/common/EmailLinkStatusCard.tsx +++ b/packages/ui/src/common/EmailLinkStatusCard.tsx @@ -10,10 +10,10 @@ import { ArrowLeftRight, ExclamationTriangle, ShieldCheck } from '../icons'; import type { InternalTheme } from '../styledSystem'; import { animations } from '../styledSystem'; -// `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'; +// `transferable` renders in the link-click tab of a `signUpIfMissing` flow: the +// email was verified but no user exists, and the original (polling) tab carries +// the flow forward as a sign-up, so this tab only tells the user to return there. +export type EmailLinkUIStatus = VerifyTokenStatus | 'verified_switch_tab' | 'loading'; type EmailLinkStatusCardProps = React.PropsWithChildren<{ title: LocalizationKey; @@ -24,6 +24,7 @@ type EmailLinkStatusCardProps = React.PropsWithChildren<{ const StatusToIcon: Record, React.ComponentType> = { verified: ShieldCheck, verified_switch_tab: ArrowLeftRight, + transferable: ArrowLeftRight, expired: ExclamationTriangle, failed: ExclamationTriangle, client_mismatch: ExclamationTriangle, @@ -33,6 +34,7 @@ const statusToColor = (theme: InternalTheme, status: Exclude { // Avoid loading flickering await sleep(750); await handleEmailLinkVerification({ redirectUrlComplete, redirectUrl }, navigate); + + // `transferable` = the email was verified but no user exists (`signUpIfMissing`). + // The originating tab's poll performs the sign-up transfer, so this tab has no + // session and nothing to complete - it only points the user back there. + if (getClerkQueryParam('__clerk_status') === 'transferable') { + setVerificationStatus('transferable'); + return; + } + setVerificationStatus('verified_switch_tab'); await sleep(750); await completeSignUpFlow({ diff --git a/packages/ui/src/components/SignIn/__tests__/SignInEmailLinkFlowComplete.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInEmailLinkFlowComplete.test.tsx new file mode 100644 index 00000000000..620d6323763 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInEmailLinkFlowComplete.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { SignInEmailLinkFlowComplete } from '../../../common/EmailLinkCompleteFlowCard'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('SignInEmailLinkFlowComplete', () => { + afterEach(() => { + window.history.replaceState({}, '', '/'); + }); + + it('shows the signed-in message when successfully verified', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + }); + render(, { wrapper }); + await waitFor(() => expect(fixtures.clerk.handleEmailLinkVerification).toHaveBeenCalled()); + await waitFor(() => { + screen.getByText(/successfully signed in/i); + }); + }); + + it('shows the email-verified message for a signUpIfMissing transfer instead of claiming a sign-in', async () => { + // The verify route lands with `__clerk_status=transferable` when the email was + // verified but no user exists; the original tab continues the flow as a sign-up. + window.history.replaceState({}, '', '/sign-in/verify?__clerk_status=transferable'); + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + }); + render(, { wrapper }); + await waitFor(() => expect(fixtures.clerk.handleEmailLinkVerification).toHaveBeenCalled()); + await waitFor(() => { + screen.getByText('Email verified'); + screen.getByText(/return to original tab/i); + }); + expect(screen.queryByText(/successfully signed in/i)).toBeNull(); + }); +});