diff --git a/.changeset/fancy-candies-slide.md b/.changeset/fancy-candies-slide.md new file mode 100644 index 00000000000..229f314249c --- /dev/null +++ b/.changeset/fancy-candies-slide.md @@ -0,0 +1,14 @@ +--- +'@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. + +On development instances, `` now logs a warning when the sign-in-or-up flow is rendered on an +instance that has both password and strict enumeration protection enabled. In that configuration +visitors without an account are routed to the password screen and cannot complete a sign-up, so the +warning names both settings and how to resolve them. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 95614c3de45..a9bf078f735 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "75KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "74KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "76KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b19474cdc65..173e93915b0 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, @@ -2288,6 +2287,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, so there is + // no session to complete here. The sign-up transfer is banked on the client that owns + // the sign-in; consuming it is left to the caller, which knows where to route next. + if (typeof params.onVerifiedOnOtherDevice === 'function') { + params.onVerifiedOnOtherDevice(); + } + return; } else if (verificationStatus !== 'verified') { throw new EmailLinkError(EmailLinkErrorCodeStatus.Failed); } @@ -2434,54 +2441,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; @@ -2627,7 +2600,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'); } @@ -2682,7 +2662,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 20b206091f4..c2de93a5030 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -100,6 +100,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'; @@ -335,8 +345,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); } @@ -1152,8 +1161,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 236b62da151..df1d5a34891 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1237,6 +1237,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 1ba1b904de0..122a4b663e4 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -1302,6 +1302,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 507be784eb5..b17434f28d8 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -1310,6 +1310,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 72af2e579f8..e4c67236ffa 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -1306,6 +1306,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 50d7f11f77c..a07a8abf12b 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -1314,6 +1314,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 74fe181dda4..633f9c0e32d 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -1314,6 +1314,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 2b0ada91bd6..7370f7421b2 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -1313,6 +1313,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 cd1c1d6056b..60bebf9779e 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -1304,6 +1304,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 f0c1b41dd1c..174badcde1a 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -1321,6 +1321,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 f97b52aae28..cca987fa536 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -1314,6 +1314,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 c6c0a1c6818..13130d87c40 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -1306,6 +1306,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 9f5c9abcc5f..cf8989060c6 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1334,6 +1334,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 e91b5452bbf..cb65757d767 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -1311,6 +1311,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 5a7885986b1..1f8cde045f8 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -1315,6 +1315,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 9607e87a56a..6cad3c3999a 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -1312,6 +1312,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 51084f69606..97c898bd26f 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -1310,6 +1310,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 0e472f1745f..fc949bea305 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -1315,6 +1315,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 7d68f3789f3..60091156541 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -1316,6 +1316,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 d1aeb515082..b4ed2ddd54e 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -1322,6 +1322,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 030d4df1157..979243d7b7b 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -1298,6 +1298,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 cb9ddad1db6..b8f93e24bd5 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -1314,6 +1314,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 46061f89292..01dde829653 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -1316,6 +1316,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 075c0cc71f9..b0bf9dc4e44 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -1318,6 +1318,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 740bdc0dbd1..714a2572e62 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -1309,6 +1309,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 3f9fd7f8f20..a0fbc74d7bd 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -1317,6 +1317,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 725555948eb..bd98f40f494 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -1314,6 +1314,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 7650af51fd1..4c0a14972ea 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -1315,6 +1315,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 61c51ac5a68..ef3d74d78ed 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -1297,6 +1297,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 e58be892784..13338d93082 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -1302,6 +1302,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 f7cb628da22..eaa86864e6f 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -1307,6 +1307,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 666b3e8ef9b..2e4f038d89b 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -1319,6 +1319,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 b8aa65cde7f..9c44aa4f49a 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -1317,6 +1317,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 b68a542214a..2a330095563 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -1307,6 +1307,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 4d4ee65b96d..feeb7fa25c3 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -1307,6 +1307,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 882d79fb7bc..54e9dc00fba 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -1307,6 +1307,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 db52fe300a0..8ad0b3e876e 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -1316,6 +1316,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 cd17667f106..834f1771885 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -1317,6 +1317,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 770217ef56f..d23abdd97fd 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -1318,6 +1318,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 80a74813621..e64a618fff3 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -1314,6 +1314,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 00d5dbb4db0..04e2afe9980 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -1307,6 +1307,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 d3bde5446ab..8a65f24c48a 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -1304,6 +1304,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 9347df7a469..56aff404efe 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -1307,6 +1307,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 deb81aea490..8f590aed60c 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -1320,6 +1320,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 3370d5b1d4c..c2a0865bdb0 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -1317,6 +1317,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 f3604cbf52f..76e0c9b0bee 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -1306,6 +1306,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 2e097ea1d5c..795b6e702c1 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -1306,6 +1306,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 9401aeaa48c..702b2ade77e 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -1304,6 +1304,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 7a1f375f3ad..e7999637023 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -1314,6 +1314,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 f4d6a3bd447..4c4062a02a1 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -1294,6 +1294,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 2ca1facbad5..36701e6f1a3 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -1297,6 +1297,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..1a0fcd44dd3 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 a sign-up transfer is + * banked on the client that owns the sign-in and whichever tab shares that + * client performs it; 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 b4ac66d3d2c..3641f36b7a7 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -511,6 +511,14 @@ export type __internal_LocalizationResource = { titleNewTab: LocalizationValue; subtitleNewTab: LocalizationValue; }; + /** + * Shown when the verified email has no matching user and the flow transfers to sign-up + * (`signUpIfMissing`), in whichever tab is not the one carrying that transfer. + */ + 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..d904dbc4f05 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 flow 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..bfdfac1d6d2 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 a `signUpIfMissing` flow: the email was verified but no user +// exists, so the tab holding the sign-in's client carries the flow forward as a sign-up +// and this card tells the user to switch to whichever tab that is. 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; + /** + * Invoked when the link lands with `__clerk_status=transferable`. Returns whether this tab took + * the flow over; when it returns false the "return to the original tab" card renders instead. + */ + onTransferable?: () => Promise; }; export const EmailLinkVerify = (props: EmailLinkVerifyProps) => { - const { redirectUrl, redirectUrlComplete, verifyEmailPath, verifyPhonePath, continuePath } = props; + const { redirectUrl, redirectUrlComplete, verifyEmailPath, verifyPhonePath, continuePath, onTransferable } = props; const { handleEmailLinkVerification } = useClerk(); const { navigate } = useRouter(); const signUp = useCoreSignUp(); @@ -31,6 +37,18 @@ export const EmailLinkVerify = (props: EmailLinkVerifyProps) => { // Avoid loading flickering await sleep(750); await handleEmailLinkVerification({ redirectUrlComplete, redirectUrl }, navigate); + + // `transferable` = the email was verified but no user exists (`signUpIfMissing`), so there + // is no session to complete here. The sign-up transfer is banked on the client that owns + // the sign-in; if that is this one, `onTransferable` carries the flow forward from this tab, + // otherwise the originating tab's poll does and this one only points the user back there. + if (getClerkQueryParam('__clerk_status') === 'transferable') { + if (!(await onTransferable?.())) { + setVerificationStatus('transferable'); + } + return; + } + setVerificationStatus('verified_switch_tab'); await sleep(750); await completeSignUpFlow({ diff --git a/packages/ui/src/components/SignIn/SignInEmailLinkVerify.tsx b/packages/ui/src/components/SignIn/SignInEmailLinkVerify.tsx new file mode 100644 index 00000000000..3ab758d42d3 --- /dev/null +++ b/packages/ui/src/components/SignIn/SignInEmailLinkVerify.tsx @@ -0,0 +1,45 @@ +import { useClerk } from '@clerk/shared/react'; + +import { SignInEmailLinkFlowComplete } from '../../common/EmailLinkCompleteFlowCard'; +import { useSignInContext } from '../../contexts'; +import { useRouter } from '../../router'; +import { handleSignUpIfMissingTransfer } from './handleSignUpIfMissingTransfer'; + +/** + * The SignIn tree's email-link verify route: the tab the verification link opened in. + * + * Mounted directly under the SignIn root, so the `../create/...` paths + * `handleSignUpIfMissingTransfer` navigates to resolve as they do from `factor-one`. + */ +export const SignInEmailLinkVerify = () => { + const clerk = useClerk(); + const { navigate } = useRouter(); + const { afterSignInUrl, afterSignUpUrl, signUpIfMissingEnabled, navigateOnSetActive, unsafeMetadata } = + useSignInContext(); + + const onTransferable = async () => { + // Mirrors `verifiedFromTheSameClient` on the polling card: whichever tab shares the client + // with the sign-in carries the flow forward. Only that client holds the banked account + // transfer, so a link opened on another device has nothing to consume here. + if (!signUpIfMissingEnabled || clerk.client.signIn.firstFactorVerification.status !== 'transferable') { + return false; + } + + await handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + navigateOnSetActive, + unsafeMetadata, + }); + return true; + }; + + return ( + + ); +}; 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..6c3ee5022b4 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,12 +28,11 @@ 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); + const [switchTabStatus, setSwitchTabStatus] = React.useState<'verified_switch_tab' | 'transferable' | null>(null); const clerk = useClerk(); React.useEffect(() => { @@ -65,7 +65,21 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard if (ver.status === 'expired') { card.setError(t(localizationKeys('formFieldError__verificationLinkExpired'))); } else if (ver.verifiedFromTheSameClient()) { - setShowVerifyModal(true); + // The tab that opened the link shares this client, so it carries the flow forward and + // this one points at it. That holds for a `transferable` verification too: the account + // transfer is banked on the client, so either tab could consume it and only one may. + setSwitchTabStatus( + signUpIfMissingEnabled && ver.status === 'transferable' ? 'transferable' : 'verified_switch_tab', + ); + } else if (signUpIfMissingEnabled && ver.status === 'transferable') { + // Verified from another client, which has no banked transfer of its own, so this tab owns it. + return handleSignUpIfMissingTransfer({ + clerk, + navigate, + afterSignUpUrl, + navigateOnSetActive, + unsafeMetadata: signInContext.unsafeMetadata, + }); } else { await completeSignInFlow(si); } @@ -87,12 +101,18 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard } }; - if (showVerifyModal) { + if (switchTabStatus) { return ( ); } 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__/SignInEmailLinkVerify.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInEmailLinkVerify.test.tsx new file mode 100644 index 00000000000..567b4e8748d --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInEmailLinkVerify.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { SignInEmailLinkVerify } from '../SignInEmailLinkVerify'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('SignInEmailLinkVerify', () => { + 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('completes the signUpIfMissing transfer when this client owns the sign-in', async () => { + window.history.replaceState({}, '', '/sign-in/verify?__clerk_status=transferable'); + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + + fixtures.signIn.firstFactorVerification = { status: 'transferable' } as any; + fixtures.signUp.create.mockResolvedValueOnce({ + status: 'missing_requirements', + missingFields: ['first_name'], + unverifiedFields: [], + } as any); + + render(, { wrapper }); + + await waitFor(() => { + expect(fixtures.signUp.create).toHaveBeenCalledWith(expect.objectContaining({ transfer: true })); + expect(fixtures.router.navigate).toHaveBeenCalledWith('../create/continue'); + }); + }); + + it('points back to the original tab when another client owns the signUpIfMissing transfer', async () => { + // The verify route lands with `__clerk_status=transferable` when the email was verified but + // no user exists. Only the sign-in's client holds the banked transfer, so a link opened on + // another device has nothing to consume here. + window.history.replaceState({}, '', '/sign-in/verify?__clerk_status=transferable'); + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + }); + props.setProps({ withSignUp: true }); + + render(, { wrapper }); + await waitFor(() => expect(fixtures.clerk.handleEmailLinkVerification).toHaveBeenCalled()); + await waitFor(() => { + screen.getByText('Email verified'); + screen.getByText(/return to original tab/i); + }); + expect(fixtures.signUp.create).not.toHaveBeenCalled(); + 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..a926335f215 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOneTransfer.test.tsx @@ -0,0 +1,285 @@ +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: 'sign_up_if_missing_transfer', 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: 'sign_up_if_missing_transfer', 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: 'sign_up_if_missing_transfer', 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: 'sign_up_if_missing_transfer', 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('defers to the newly opened tab when a transferable email link was verified from the same client', 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 }); + + // The account transfer is banked once on the shared client, so the tab that opened the + // link consumes it and this one must not race for it. + fixtures.signIn.createEmailLinkFlow.mockReturnValue({ + startEmailLinkFlow: vi.fn().mockResolvedValue({ + status: 'needs_first_factor', + firstFactorVerification: { + status: 'transferable', + verifiedFromTheSameClient: () => true, + }, + }), + cancelEmailLinkFlow: vi.fn(), + } 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(() => { + screen.getByText('Email verified'); + screen.getByText(/newly opened tab/i); + }); + 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: 'sign_up_if_missing_transfer', 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__/SignInStartPasswordWarning.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStartPasswordWarning.test.tsx new file mode 100644 index 00000000000..ef328662363 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInStartPasswordWarning.test.tsx @@ -0,0 +1,73 @@ +import { logger } from '@clerk/shared/logger'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { SignInStart } from '../SignInStart'; + +vi.mock('@clerk/shared/logger', () => ({ + logger: { + warnOnce: vi.fn(), + logOnce: vi.fn(), + }, +})); + +const { createFixtures } = bindCreateFixtures('SignIn'); + +// Fixtures build Clerk from a production publishable key to skip the devInit flow, but the warning +// is gated on a development instance, so shadow the prototype getter. +const asDevelopmentInstance = (clerk: unknown) => + Object.defineProperty(clerk as object, 'instanceType', { value: 'development', configurable: true }); + +const warnings = () => + vi + .mocked(logger.warnOnce) + .mock.calls.map(call => call[0]) + .join('\n'); + +describe('SignInStart sign-in-or-up password warning', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('warns when the sign-in-or-up flow renders on a password-enabled instance', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.withEnumerationProtection(); + }); + asDevelopmentInstance(fixtures.clerk); + props.setProps({ withSignUp: true }); + + render(, { wrapper }); + + expect(warnings()).toContain('sign_up_if_missing_password_preferred'); + }); + + it('does not warn when password is disabled', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withEnumerationProtection(); + }); + asDevelopmentInstance(fixtures.clerk); + props.setProps({ withSignUp: true }); + + render(, { wrapper }); + + expect(warnings()).not.toContain('sign_up_if_missing_password_preferred'); + }); + + it('does not warn outside the sign-in-or-up flow', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEmailAddress(); + f.withPassword(); + f.withEnumerationProtection(); + }); + asDevelopmentInstance(fixtures.clerk); + + render(, { wrapper }); + + expect(warnings()).not.toContain('sign_up_if_missing_password_preferred'); + }); +}); 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/components/SignIn/index.tsx b/packages/ui/src/components/SignIn/index.tsx index ea2e099cec9..6cea9742adb 100644 --- a/packages/ui/src/components/SignIn/index.tsx +++ b/packages/ui/src/components/SignIn/index.tsx @@ -2,7 +2,7 @@ import { useClerk } from '@clerk/shared/react'; import type { SignInModalProps, SignInProps } from '@clerk/shared/types'; import React from 'react'; -import { SignInEmailLinkFlowComplete, SignUpEmailLinkFlowComplete } from '@/common/EmailLinkCompleteFlowCard'; +import { SignUpEmailLinkFlowComplete } from '@/common/EmailLinkCompleteFlowCard'; import { SignInContext, SignUpContext, @@ -34,6 +34,7 @@ import { ResetPassword } from './ResetPassword'; import { ResetPasswordSuccess } from './ResetPasswordSuccess'; import { SignInAccountSwitcher } from './SignInAccountSwitcher'; import { SignInClientTrust } from './SignInClientTrust'; +import { SignInEmailLinkVerify } from './SignInEmailLinkVerify'; import { SignInFactorOne } from './SignInFactorOne'; import { SignInFactorTwo } from './SignInFactorTwo'; import { SignInProtectCheck } from './SignInProtectCheck'; @@ -88,10 +89,7 @@ function SignInRoutes(): JSX.Element { - + {signInContext.isCombinedFlow && ( diff --git a/packages/ui/src/contexts/components/SignIn.ts b/packages/ui/src/contexts/components/SignIn.ts index 6337fcb5d32..2d44b0bb838 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'; @@ -12,6 +12,7 @@ import { useEnvironment, useOptions } from '../../contexts'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; import type { SignInCtx } from '../../types'; +import { warnAboutPasswordInSignInOrUpFlow } from '../../utils/warnAboutPasswordInSignInOrUpFlow'; import { clerkWindowNavigate } from '../../utils/windowNavigate'; import { getInitialValuesFromQueryParams } from '../utils'; @@ -30,6 +31,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. + // `attackProtection` is optional-chained because it is introduced alongside this flow: an app + // pinned to an older clerk-js runtime builds a `UserSettings` resource without the field. + const signUpIfMissingEnabled = + isCombinedFlow && + Boolean(userSettings.attackProtection?.enumeration_protection?.enabled) && + signUpMode === SIGN_UP_MODES.PUBLIC; + + if (clerk.instanceType === 'development') { + warnAboutPasswordInSignInOrUpFlow({ + signUpIfMissingEnabled, + passwordEnabled: userSettings.instanceIsPasswordBased, + }); + } + 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 +217,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, + }, + }, }; }; diff --git a/packages/ui/src/utils/__tests__/warnAboutPasswordInSignInOrUpFlow.test.ts b/packages/ui/src/utils/__tests__/warnAboutPasswordInSignInOrUpFlow.test.ts new file mode 100644 index 00000000000..5a3dd5df711 --- /dev/null +++ b/packages/ui/src/utils/__tests__/warnAboutPasswordInSignInOrUpFlow.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// Mock the dependencies before importing the module +vi.mock('@clerk/shared/logger', () => ({ + logger: { + warnOnce: vi.fn(), + }, +})); + +import { logger } from '@clerk/shared/logger'; + +import { warnAboutPasswordInSignInOrUpFlow } from '../warnAboutPasswordInSignInOrUpFlow'; + +const getWarningMessage = () => { + const calls = vi.mocked(logger.warnOnce).mock.calls; + return calls.length > 0 ? calls[0][0] : null; +}; + +describe('warnAboutPasswordInSignInOrUpFlow', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('warns when the sign-in-or-up flow runs on a password-enabled instance', () => { + warnAboutPasswordInSignInOrUpFlow({ signUpIfMissingEnabled: true, passwordEnabled: true }); + + expect(logger.warnOnce).toHaveBeenCalledTimes(1); + const message = getWarningMessage(); + expect(message).toContain('sign_up_if_missing_password_preferred'); + // Both triggering settings must be named, so the reader knows what to change. + expect(message).toContain('password'); + expect(message).toContain('strict enumeration protection'); + }); + + test('does not warn when the sign-in-or-up flow is not active', () => { + warnAboutPasswordInSignInOrUpFlow({ signUpIfMissingEnabled: false, passwordEnabled: true }); + + expect(logger.warnOnce).not.toHaveBeenCalled(); + }); + + test('does not warn when password is disabled, since new users can complete a code factor', () => { + warnAboutPasswordInSignInOrUpFlow({ signUpIfMissingEnabled: true, passwordEnabled: false }); + + expect(logger.warnOnce).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/utils/warnAboutPasswordInSignInOrUpFlow.ts b/packages/ui/src/utils/warnAboutPasswordInSignInOrUpFlow.ts new file mode 100644 index 00000000000..f31d0025275 --- /dev/null +++ b/packages/ui/src/utils/warnAboutPasswordInSignInOrUpFlow.ts @@ -0,0 +1,49 @@ +import { logger } from '@clerk/shared/logger'; + +function formatPasswordInSignInOrUpWarning(): string { + return [ + `Clerk: is rendering the sign-in-or-up flow, but visitors without an account cannot`, + `complete it on this instance.`, + ``, + `This is caused by two instance settings being enabled together:`, + ` - password`, + ` - strict enumeration protection`, + ``, + `Strict enumeration protection makes a sign-in for an unknown identifier advertise every first`, + `factor the instance supports, password included, and password is the preferred factor by default.`, + `A visitor who does not have an account is therefore routed to the password screen, where nothing`, + `they type can succeed and no sign-up transfer is possible. They have to find "Use another method"`, + `and pick a code or link factor before the flow can create their account.`, + ``, + `A wrong password and a non-existent user are deliberately indistinguishable to the client, so the`, + `component cannot detect this and recover on its own. To make the flow work for new users, either`, + `disable password on the instance or set the instance's preferred sign-in strategy to OTP.`, + ``, + `Learn more: https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options`, + `(code=sign_up_if_missing_password_preferred)`, + ].join('\n'); +} + +/** + * Warns when the sign-in-or-up flow is active alongside password, a combination in which visitors + * without an account are routed to a first factor they cannot complete. + * + * `signUpIfMissingEnabled` already encodes the combined flow, enumeration protection, and a public + * sign-up mode, so password being enabled is the only additional condition. + * + * Note: The caller should check clerk.instanceType === 'development' before calling. + * This function assumes it's only called in development mode. + */ +export function warnAboutPasswordInSignInOrUpFlow({ + signUpIfMissingEnabled, + passwordEnabled, +}: { + signUpIfMissingEnabled: boolean; + passwordEnabled: boolean; +}): void { + if (!signUpIfMissingEnabled || !passwordEnabled) { + return; + } + + logger.warnOnce(formatPasswordInSignInOrUpWarning()); +}