diff --git a/.changeset/fancy-candies-slide.md b/.changeset/fancy-candies-slide.md new file mode 100644 index 00000000000..75e710973a8 --- /dev/null +++ b/.changeset/fancy-candies-slide.md @@ -0,0 +1,9 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +'@clerk/localizations': minor +--- + +Support sign-in-or-sign-up combined flow with Clerk component +when strict enumeration protection is enabled. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 81714df9bc6..aedee9e8b1b 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -121,7 +121,6 @@ import type { SignOut, SignOutCallback, SignOutOptions, - SignUpField, SignUpProps, SignUpRedirectOptions, SignUpResource, @@ -149,7 +148,6 @@ import { ModuleManager } from '@/utils/moduleManager'; import { ALLOWED_PROTOCOLS, buildURL, - completeSignUpFlow, createAllowedRedirectOrigins, createBeforeUnloadTracker, createPageLifecycle, @@ -162,6 +160,7 @@ import { isError, isOrganizationId, isRedirectForFAPIInitiatedFlow, + navigateToNextStepSignUp, removeClerkQueryParam, requiresUserInput, stripOrigin, @@ -2273,6 +2272,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); } @@ -2419,54 +2426,20 @@ 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 navigateToSignUpProtectCheck = makeNavigate( + 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 signUpProtectCheckUrl = params.signUpProtectCheckUrl || - buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), - ); + buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }); - const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => { - // 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 - // OAuth/SAML callback would land on /continue instead of the challenge. - if (signUp.protectCheck || missingFields.includes('protect_check')) { - return navigateToSignUpProtectCheck(); - } - - 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 }), - protectCheckPath: - params.signUpProtectCheckUrl || - buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }), - navigate, - }); - }; + const navigateToSignUpProtectCheck = makeNavigate(signUpProtectCheckUrl); const signInUrl = params.signInUrl || displayConfig.signInUrl; const signUpUrl = params.signUpUrl || displayConfig.signUpUrl; @@ -2612,7 +2585,14 @@ export class Clerk implements ClerkInterface { }, }); case 'missing_requirements': - return navigateToNextStepSignUp({ missingFields: res.missingFields }); + return navigateToNextStepSignUp({ + signUp: res, + continueSignUpUrl, + verifyEmailAddressUrl, + verifyPhoneNumberUrl, + signUpProtectCheckUrl, + navigate, + }); default: clerkOAuthCallbackDidNotCompleteSignInSignUp('sign in'); } @@ -2667,7 +2647,14 @@ export class Clerk implements ClerkInterface { } if (su.externalAccountStatus === 'verified' && su.status === 'missing_requirements') { - return navigateToNextStepSignUp({ missingFields: signUp.missingFields }); + return navigateToNextStepSignUp({ + signUp, + continueSignUpUrl, + verifyEmailAddressUrl, + verifyPhoneNumberUrl, + signUpProtectCheckUrl, + navigate, + }); } if (this.session?.currentTask) { diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 68b99b38dfe..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') { + 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') { + 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 aaabb6738b6..86c928f6d74 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, self_serve_sso: false, @@ -214,6 +216,15 @@ export class UserSettings extends BaseResource implements UserSettingsResource { this.attributes, ); this.actions = this.withDefault(data.actions, this.actions); + // 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 @@ -252,6 +263,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { public __internal_toSnapshot(): UserSettingsJSONSnapshot { return { actions: this.actions, + attack_protection: this.attackProtection, attributes: this.attributes, passkey_settings: this.passkeySettings, password_settings: this.passwordSettings, diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 628b665fc7c..37188ca94cc 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -994,6 +994,37 @@ describe('SignIn', () => { expect.anything(), ); }); + + it('polls until firstFactorVerification status is transferable', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { status: 'unverified' }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { status: 'transferable' }, + }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ id: 'signin_123' } as any); + await signIn.__internal_future.emailLink.waitForVerification(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/client/sign_ins/signin_123', + }), + expect.anything(), + ); + }); }); describe('sendPhoneCode', () => { diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index 2a66443941e..db9d7631927 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './beforeUnloadTracker'; export * from './billing'; export * from '@clerk/shared/internal/clerk-js/completeSignUpFlow'; +export * from '@clerk/shared/internal/clerk-js/navigateToNextStepSignUp'; export * from '@clerk/shared/internal/clerk-js/email'; export * from '@clerk/shared/internal/clerk-js/encoders'; export * from './errors'; diff --git a/packages/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/__tests__/navigateToNextStepSignUp.test.ts b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts new file mode 100644 index 00000000000..99f560a5389 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/__tests__/navigateToNextStepSignUp.test.ts @@ -0,0 +1,142 @@ +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', + signUpProtectCheckUrl: 'https://app.test/sign-up/protect-check', +}; + +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, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.continueSignUpUrl); + }); + + it('navigates to the protect-check page when the sign-up is protect-gated, before checking missing fields', async () => { + const signUp = { + status: 'missing_requirements', + missingFields: ['protect_check', 'first_name'] as SignUpField[], + unverifiedFields: [], + } as unknown as SignUpResource; + + await navigateToNextStepSignUp({ + signUp, + ...URLS, + navigate: mockNavigate, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(URLS.signUpProtectCheckUrl); + }); + + 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, + ...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, + ...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, + ...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, + ...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, + ...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..277b8ede011 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/navigateToNextStepSignUp.ts @@ -0,0 +1,54 @@ +import type { SignUpResource } from '../../types'; +import { completeSignUpFlow } from './completeSignUpFlow'; + +type NavigateToNextStepSignUpProps = { + signUp: SignUpResource; + continueSignUpUrl: string; + verifyEmailAddressUrl: string; + verifyPhoneNumberUrl: string; + signUpProtectCheckUrl: string; + navigate: (to: string, options?: { searchParams?: URLSearchParams }) => Promise; +}; + +/** + * Routes a sign-up that's still in `missing_requirements` to the appropriate + * next step: + * + * - If the sign-up is protect-gated, go to the protect-check challenge. + * - Otherwise, 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, + continueSignUpUrl, + verifyEmailAddressUrl, + verifyPhoneNumberUrl, + signUpProtectCheckUrl, + navigate, +}: NavigateToNextStepSignUpProps): Promise | undefined => { + // 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 || signUp.missingFields.includes('protect_check')) { + return navigate(signUpProtectCheckUrl); + } + + if (signUp.missingFields.length) { + return navigate(continueSignUpUrl); + } + + return completeSignUpFlow({ + signUp, + verifyEmailPath: verifyEmailAddressUrl, + verifyPhonePath: verifyPhoneNumberUrl, + protectCheckPath: signUpProtectCheckUrl, + navigate, + }); +}; diff --git a/packages/shared/src/internal/clerk-js/queryParams.ts b/packages/shared/src/internal/clerk-js/queryParams.ts index 8bea94a7c9b..6d16482231b 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` 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' | (typeof EmailLinkErrorCodeStatus)[keyof typeof EmailLinkErrorCodeStatus]; +export type VerifyTokenStatus = + | 'verified' + | 'transferable' + | (typeof EmailLinkErrorCodeStatus)[keyof typeof EmailLinkErrorCodeStatus]; /** * Used for instance invitations and organization invitations diff --git a/packages/shared/src/types/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/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index dafa0190251..ec5a599a2f6 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -82,6 +82,12 @@ export type UsernameSettingsData = { max_length: number; }; +export type AttackProtectionData = { + enumeration_protection: { + enabled: boolean; + }; +}; + export type PasskeySettingsData = { allow_autofill: boolean; show_sign_in_button: boolean; @@ -122,6 +128,11 @@ export interface UserSettingsJSON extends ClerkResourceJSON { password_settings: PasswordSettingsData; passkey_settings: PasskeySettingsData; username_settings: UsernameSettingsData; + /** + * 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 { @@ -136,6 +147,7 @@ export interface UserSettingsResource extends ClerkResource { signUp: SignUpData; passwordSettings: PasswordSettingsData; usernameSettings: UsernameSettingsData; + attackProtection: AttackProtectionData; passkeySettings: PasskeySettingsData; socialProviderStrategies: OAuthStrategy[]; authenticatableSocialStrategies: OAuthStrategy[]; diff --git a/packages/ui/src/common/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 a6350be2731..611858279f0 100644 --- a/packages/ui/src/common/EmailLinkStatusCard.tsx +++ b/packages/ui/src/common/EmailLinkStatusCard.tsx @@ -10,6 +10,9 @@ import { ArrowLeftRight, ExclamationTriangle, ShieldCheck } from '../icons'; import type { InternalTheme } from '../styledSystem'; import { animations } from '../styledSystem'; +// `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<{ @@ -21,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, @@ -30,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/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx index 809a0aa0cd7..e18f44c00e9 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx @@ -15,6 +15,7 @@ import { useSupportEmail } from '../../hooks/useSupportEmail'; import { type LocalizationKey } from '../../localization'; import { useRouter } from '../../router'; import { navigateOnSignInProtectGate } from './handleProtectCheck'; +import { handleSignUpIfMissingTransfer } from './handleSignUpIfMissingTransfer'; export type SignInFactorOneCodeCard = Pick< VerificationCodeCardProps, @@ -37,7 +38,8 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => const signIn = useCoreSignIn(); const card = useCardState(); const { navigate } = useRouter(); - const { afterSignInUrl, navigateOnSetActive } = useSignInContext(); + const ctx = useSignInContext(); + const { afterSignInUrl, afterSignUpUrl, signUpIfMissingEnabled, navigateOnSetActive } = ctx; const { setActive } = useClerk(); const supportEmail = useSupportEmail(); const clerk = useClerk(); @@ -141,6 +143,24 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => return clerk.__internal_navigateWithError('..', err.errors[0]); } + 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 204415685bb..258312ad481 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -15,6 +15,7 @@ import { useCardState } from '../../elements/contexts'; import { useEmailLink } from '../../hooks/useEmailLink'; import { useRouter } from '../../router/RouteContext'; import { navigateOnSignInProtectGate } from './handleProtectCheck'; +import { handleSignUpIfMissingTransfer } from './handleSignUpIfMissingTransfer'; type SignInFactorOneEmailLinkCardProps = Pick & { factor: EmailLinkFactor; @@ -27,9 +28,8 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const card = useCardState(); const signIn = useCoreSignIn(); const signInContext = useSignInContext(); - const { signInUrl } = signInContext; + const { signInUrl, afterSignInUrl, afterSignUpUrl, signUpIfMissingEnabled, navigateOnSetActive } = signInContext; const { navigate } = useRouter(); - const { afterSignInUrl } = useSignInContext(); const { setActive } = useClerk(); const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn); const [showVerifyModal, setShowVerifyModal] = React.useState(false); @@ -64,6 +64,14 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const ver = si.firstFactorVerification; if (ver.status === 'expired') { card.setError(t(localizationKeys('formFieldError__verificationLinkExpired'))); + } else if (signUpIfMissingEnabled && 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 231a7e57798..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,7 +388,19 @@ function SignInStartInternal(): JSX.Element { } as any); } try { - const res = await safePasswordSignInForEnterpriseSSOInstance(signIn.create(buildSignInParams(fields)), fields); + // 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 shouldSignUpIfMissing = signUpIfMissingEnabled && signUpAttribute !== 'username' && !hasPassword; + + const res = await safePasswordSignInForEnterpriseSSOInstance( + signIn.create({ + ...buildSignInParams(fields), + ...(shouldSignUpIfMissing && { signUpIfMissing: true }), + }), + fields, + ); if (navigateOnSignInProtectGate(res, navigate, 'protect-check')) { return; 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(); + }); +}); 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..b2930bb48e2 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx @@ -0,0 +1,249 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { SignInResource } from '@clerk/shared/types'; +import { waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { SignInFactorOne } from '../SignInFactorOne'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('SignInFactorOne sign-up-if-missing transfer', () => { + it('triggers sign-up transfer when attemptFirstFactor fails with transferable status', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + // The SDK updates firstFactorVerification on the resource *before* throwing + // the API error. This coupling is intentional — the component reads the + // resource status inside the catch block to decide whether to transfer. + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + fixtures.clerk.client.sessions = [{ id: 'sess_123' }] as any; + fixtures.signUp.create.mockResolvedValueOnce({ status: 'complete', createdSessionId: 'sess_123' } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + // 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 () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + fixtures.signUp.create.mockResolvedValueOnce({ + status: 'missing_requirements', + missingFields: ['first_name'], + unverifiedFields: [], + } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + // 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 () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + }); + }); + + it('does not trigger transfer when not in combined flow', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + }); + }); + + it('proceeds to second factor for existing users (no transfer)', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockResolvedValueOnce({ + status: 'needs_second_factor', + firstFactorVerification: { status: 'verified' }, + } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.type(screen.getByLabelText(/Enter verification code/i), '123456'); + await waitFor(() => { + expect(fixtures.router.navigate).toHaveBeenCalledWith('../factor-two'); + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + }); + }); + + it('triggers sign-up transfer when email link verification becomes transferable', async () => { + const email = 'test@clerk.com'; + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.withPreferredSignInStrategy({ strategy: 'password' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailLink: true, identifier: email }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.createEmailLinkFlow.mockReturnValue({ + startEmailLinkFlow: vi.fn().mockResolvedValue({ + status: 'needs_first_factor', + firstFactorVerification: { + status: 'transferable', + verifiedFromTheSameClient: () => false, + }, + }), + cancelEmailLinkFlow: vi.fn(), + } as any); + fixtures.signUp.create.mockResolvedValueOnce({ + status: 'missing_requirements', + missingFields: ['first_name'], + unverifiedFields: [], + } as any); + + const { userEvent } = render(, { wrapper }); + + await userEvent.click(await screen.findByText('Use another method')); + await userEvent.click(await screen.findByText(`Email link to ${email}`)); + + await waitFor(() => { + expect(fixtures.signUp.create).toHaveBeenCalledWith( + expect.objectContaining({ + transfer: true, + }), + ); + expect(fixtures.router.navigate).toHaveBeenCalledWith('../create/continue'); + }); + }); + + it('surfaces transfer errors instead of leaving the code form loading', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPreferredSignInStrategy({ strategy: 'otp' }); + f.withEnumerationProtection(); + f.startSignInWithEmailAddress({ supportEmailCode: true, supportPassword: false }); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource)); + fixtures.signIn.attemptFirstFactor.mockImplementationOnce(() => { + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + return Promise.reject( + new ClerkAPIResponseError('Error', { + data: [{ code: 'form_identifier_not_found', long_message: '', message: '' }], + status: 404, + }), + ); + }); + fixtures.signUp.create.mockResolvedValueOnce({ status: 'abandoned' } as any); + + const { userEvent } = render(, { wrapper }); + const input = screen.getByLabelText(/Enter verification code/i); + + await userEvent.type(input, '123456'); + + // 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__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 28cbbb00f82..9f71a356fd5 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -696,6 +696,134 @@ describe('SignInStart', () => { }); }); + describe('signUpIfMissing', () => { + it('passes signUpIfMissing: true when combined flow and enumeration protection are enabled', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when enumeration protection is disabled', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when not in combined flow', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when password is present', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword({ required: true }); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { container, userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + const passwordField = container.querySelector('#password-field') as Element; + expect(passwordField).not.toBeNull(); + fireEvent.change(passwordField, { target: { value: 'some-password' } }); + const form = container.querySelector('form') as Element; + fireEvent.submit(form); + await waitFor(() => { + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + }); + + it('does not pass signUpIfMissing when sign-up mode is restricted', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + f.withRestrictedMode(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when sign-up mode is waitlist', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + f.withWaitlistMode(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + + it('does not pass signUpIfMissing when the identifier is a username', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUsername(); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + fixtures.signIn.create.mockReturnValueOnce(Promise.resolve({ status: 'needs_first_factor' } as SignInResource)); + const { userEvent } = render(, { wrapper }); + await userEvent.type(screen.getByLabelText(/username/i), 'hello'); + await userEvent.click(screen.getByText('Continue')); + expect(fixtures.signIn.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + signUpIfMissing: true, + }), + ); + }); + }); + describe('ticket flow', () => { it('calls the appropriate resource function upon detecting the ticket', async () => { const { wrapper, fixtures } = await createFixtures(f => { diff --git a/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts new file mode 100644 index 00000000000..1072b78b499 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/handleSignUpIfMissingTransfer.test.ts @@ -0,0 +1,194 @@ +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: { + sessions: [], + signUp: { + create: vi.fn().mockResolvedValue(signUpCreateResult), + }, + reload: vi.fn(), + }, + navigate: vi.fn(), + setActive: vi.fn(), + } as unknown as LoadedClerk; +}; + +describe('handleSignUpIfMissingTransfer', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should call signUp.create with transfer: true', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + 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('delegates post-setActive navigation to navigateOnSetActive with afterSignUpUrl', async () => { + const clerk = createMockClerk({ status: 'complete', createdSessionId: 'sess_123' }) as LoadedClerk & { + setActive: ReturnType; + }; + + const session = { currentTask: null } as any; + const decorateUrl = (url: string) => url; + + clerk.setActive.mockImplementation(async params => { + await params.navigate({ session, decorateUrl }); + }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + expect(mockNavigateOnSetActive).toHaveBeenCalledWith({ + session, + redirectUrl: 'https://test.com', + decorateUrl, + }); + }); + + 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', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0] as string).toBe('../create/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', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0] as string).toBe('../create/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, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0] as string).toBe('../create/verify-phone-number'); + expect(clerk.setActive).not.toHaveBeenCalled(); + }); + + it('routes to protect-check when the sign-up is protect-gated', async () => { + const clerk = createMockClerk({ + status: 'missing_requirements', + missingFields: ['protect_check', 'first_name'], + unverifiedFields: [], + }); + + await handleSignUpIfMissingTransfer({ + clerk, + navigate: mockNavigate, + afterSignUpUrl: 'https://test.com', + navigateOnSetActive: mockNavigateOnSetActive, + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate.mock.calls[0][0] as string).toBe('../create/protect-check'); + 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..e22626c08b2 --- /dev/null +++ b/packages/ui/src/components/SignIn/handleSignUpIfMissingTransfer.ts @@ -0,0 +1,71 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { navigateToNextStepSignUp } from '@clerk/shared/internal/clerk-js/navigateToNextStepSignUp'; +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; + navigateOnSetActive: SignInContextType['navigateOnSetActive']; + unsafeMetadata?: SignUpUnsafeMetadata; +}; + +/** + * Handles transferring from sign-in to sign-up when the backend returns + * `firstFactorVerification.status === 'transferable'` (i.e. the user does not + * exist and `signUpIfMissing` was used). + * + * This mirrors the OAuth transfer handling in `_handleRedirectCallback`, 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, + 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 }) => { + // 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 + // email/phone identifications to their verify pages. + return navigateToNextStepSignUp({ + signUp: res, + continueSignUpUrl: '../create/continue', + verifyEmailAddressUrl: '../create/verify-email-address', + verifyPhoneNumberUrl: '../create/verify-phone-number', + signUpProtectCheckUrl: '../create/protect-check', + navigate, + }); + default: + throw new ClerkRuntimeError(`Unexpected sign-up status after transfer: ${res.status}`, { + code: 'sign_up_transfer_unexpected_status', + }); + } +} diff --git a/packages/ui/src/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, }; diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index 2f33a1b36e8..9508c2ffe75 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -636,6 +636,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 { @@ -657,5 +665,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, + }, + }, }; };