Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fancy-candies-slide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/ui': minor
'@clerk/localizations': minor
---

Support sign-in-or-sign-up combined flow with Clerk <SignIn> component
when strict enumeration protection is enabled.
85 changes: 36 additions & 49 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ import type {
SignOut,
SignOutCallback,
SignOutOptions,
SignUpField,
SignUpProps,
SignUpRedirectOptions,
SignUpResource,
Expand Down Expand Up @@ -149,7 +148,6 @@ import { ModuleManager } from '@/utils/moduleManager';
import {
ALLOWED_PROTOCOLS,
buildURL,
completeSignUpFlow,
createAllowedRedirectOrigins,
createBeforeUnloadTracker,
createPageLifecycle,
Expand All @@ -162,6 +160,7 @@ import {
isError,
isOrganizationId,
isRedirectForFAPIInitiatedFlow,
navigateToNextStepSignUp,
removeClerkQueryParam,
requiresUserInput,
stripOrigin,
Expand Down Expand Up @@ -2273,6 +2272,14 @@ export class Clerk implements ClerkInterface {
throw new EmailLinkError(EmailLinkErrorCodeStatus.Expired);
} else if (verificationStatus === 'client_mismatch') {
throw new EmailLinkError(EmailLinkErrorCodeStatus.ClientMismatch);
} else if (verificationStatus === 'transferable') {
// signUpIfMissing flow: the email was verified but the user doesn't exist.
// The polling tab handles the actual sign-up transfer, so treat this
// the same as verified-on-other-device for the link-click tab.
if (typeof params.onVerifiedOnOtherDevice === 'function') {
params.onVerifiedOnOtherDevice();
}
return;
} else if (verificationStatus !== 'verified') {
throw new EmailLinkError(EmailLinkErrorCodeStatus.Failed);
}
Expand Down Expand Up @@ -2419,54 +2426,20 @@ export class Clerk implements ClerkInterface {

const redirectUrls = new RedirectUrls(this.#options, params);

const navigateToContinueSignUp = makeNavigate(
const continueSignUpUrl =
params.continueSignUpUrl ||
buildURL(
{
base: displayConfig.signUpUrl,
hashPath: '/continue',
},
{ stringify: true },
),
);

const navigateToSignUpProtectCheck = makeNavigate(
buildURL({ base: displayConfig.signUpUrl, hashPath: '/continue' }, { stringify: true });
const verifyEmailAddressUrl =
params.verifyEmailAddressUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-email-address' }, { stringify: true });
const verifyPhoneNumberUrl =
params.verifyPhoneNumberUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true });
const signUpProtectCheckUrl =
params.signUpProtectCheckUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
);
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true });

const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => {
// A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate
// check must run BEFORE the generic missing-fields short-circuit below — otherwise the
// OAuth/SAML callback would land on /continue instead of the challenge.
if (signUp.protectCheck || missingFields.includes('protect_check')) {
return navigateToSignUpProtectCheck();
}

if (missingFields.length) {
return navigateToContinueSignUp();
}

return completeSignUpFlow({
signUp,
verifyEmailPath:
params.verifyEmailAddressUrl ||
buildURL(
{
base: displayConfig.signUpUrl,
hashPath: '/verify-email-address',
},
{ stringify: true },
),
verifyPhonePath:
params.verifyPhoneNumberUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }),
protectCheckPath:
params.signUpProtectCheckUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
navigate,
});
};
const navigateToSignUpProtectCheck = makeNavigate(signUpProtectCheckUrl);

const signInUrl = params.signInUrl || displayConfig.signInUrl;
const signUpUrl = params.signUpUrl || displayConfig.signUpUrl;
Expand Down Expand Up @@ -2612,7 +2585,14 @@ export class Clerk implements ClerkInterface {
},
});
case 'missing_requirements':
return navigateToNextStepSignUp({ missingFields: res.missingFields });
return navigateToNextStepSignUp({
signUp: res,
continueSignUpUrl,
verifyEmailAddressUrl,
verifyPhoneNumberUrl,
signUpProtectCheckUrl,
navigate,
});
default:
clerkOAuthCallbackDidNotCompleteSignInSignUp('sign in');
}
Expand Down Expand Up @@ -2667,7 +2647,14 @@ export class Clerk implements ClerkInterface {
}

if (su.externalAccountStatus === 'verified' && su.status === 'missing_requirements') {
return navigateToNextStepSignUp({ missingFields: signUp.missingFields });
return navigateToNextStepSignUp({
signUp,
continueSignUpUrl,
verifyEmailAddressUrl,
verifyPhoneNumberUrl,
signUpProtectCheckUrl,
navigate,
});
}

if (this.session?.currentTask) {
Expand Down
16 changes: 12 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ import {
import { eventBus } from '../events';
import { BaseResource, UserData, Verification } from './internal';

/**
* Terminal states for email-link verification polling: `verified` (success), `expired`
* (link timed out), or `transferable` (`signUpIfMissing` flows — the address was verified
* but no user exists, so the caller transfers to sign-up). Shared by the legacy
* `createEmailLinkFlow` poll and `SignInFuture.waitForEmailLinkVerification` so the two
* loops can't drift apart.
*/
const isTerminalEmailLinkVerificationStatus = (status: string | null) =>
status === 'verified' || status === 'expired' || status === 'transferable';

export class SignIn extends BaseResource implements SignInResource {
pathRoot = '/client/sign_ins';

Expand Down Expand Up @@ -333,8 +343,7 @@ export class SignIn extends BaseResource implements SignInResource {
void run(() => {
return this.reload()
.then(res => {
const status = res[verificationKey].status;
if (status === 'verified' || status === 'expired') {
if (isTerminalEmailLinkVerificationStatus(res[verificationKey].status)) {
stop();
resolve(res);
}
Expand Down Expand Up @@ -1145,8 +1154,7 @@ class SignInFuture implements SignInFutureResource {
void run(async () => {
try {
const res = await this.#resource.__internal_baseGet();
const status = res.firstFactorVerification.status;
if (status === 'verified' || status === 'expired') {
if (isTerminalEmailLinkVerificationStatus(res.firstFactorVerification.status)) {
stop();
resolve(res);
}
Expand Down
12 changes: 12 additions & 0 deletions packages/clerk-js/src/core/resources/UserSettings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
AttackProtectionData,
Attributes,
EnterpriseSSOSettings,
OAuthProviders,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,37 @@ describe('SignIn', () => {
expect.anything(),
);
});

it('polls until firstFactorVerification status is transferable', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
client: null,
response: {
id: 'signin_123',
first_factor_verification: { status: 'unverified' },
},
})
.mockResolvedValueOnce({
client: null,
response: {
id: 'signin_123',
first_factor_verification: { status: 'transferable' },
},
});
BaseResource._fetch = mockFetch;

const signIn = new SignIn({ id: 'signin_123' } as any);
await signIn.__internal_future.emailLink.waitForVerification();

expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
path: '/client/sign_ins/signin_123',
}),
expect.anything(),
);
});
});

describe('sendPhoneCode', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,10 @@ export const arSA: LocalizationResource = {
subtitleNewTab: 'ارجع إلى علامة التبويب المفتوحة حديثًا للمتابعة',
titleNewTab: 'تم تسجيل الدخول في علامة تبويب أخرى',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'استخدم رابط التحقق المرسل إلى بريدك الإلكتروني',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,10 @@ export const beBY: LocalizationResource = {
subtitleNewTab: 'Верніцеся на толькі што адчыненую ўкладку, каб працягнуць',
titleNewTab: 'Залогіньцеся на іншай укладцы',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Выкарыстоўвайце спасылку для пацвярджэння, адпраўленую на вашу электронную пошту',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,10 @@ export const bgBG: LocalizationResource = {
subtitleNewTab: 'Върнете се в новоотворения таб, за да продължите',
titleNewTab: 'Влезнали сте в друг таб',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Използвайте връзката за потвърждение, изпратена на вашия имейл',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/bn-IN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,10 @@ export const bnIN: LocalizationResource = {
subtitleNewTab: 'চালিয়ে যেতে নতুন খোলা ট্যাবে ফিরে যান',
titleNewTab: 'অন্য ট্যাবে সাইন ইন হয়েছে',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'আপনার ইমেইলে পাঠানো যাচাইকরণ লিঙ্কটি ব্যবহার করুন',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/ca-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,10 @@ export const caES: LocalizationResource = {
subtitleNewTab: 'Torna a la pestanya recentment oberta per continuar',
titleNewTab: "S'ha iniciat sessió en una altra pestanya",
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: "Utilitzeu l'enllaç de verificació enviat al vostre correu electrònic",
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/cs-CZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,10 @@ export const csCZ: LocalizationResource = {
subtitleNewTab: 'Vraťte se na nově otevřenou kartu pro pokračování',
titleNewTab: 'Přihlášeno na jiné kartě',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Použijte ověřovací odkaz zaslaný na váš e-mail',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/da-DK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,10 @@ export const daDK: LocalizationResource = {
subtitleNewTab: 'Vend tilbage til den nyligt åbnede fane for at fortsætte',
titleNewTab: 'Logget ind på anden fane',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Brug bekræftelseslinket, der er sendt til din e-mail',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/de-DE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,10 @@ export const deDE: LocalizationResource = {
subtitleNewTab: 'Kehren Sie zum neu geöffneten Tab zurück, um fortzufahren',
titleNewTab: 'In einem anderen Tab angemeldet',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Verwenden Sie den an Ihre E-Mail gesendeten Bestätigungslink',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/el-GR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,10 @@ export const elGR: LocalizationResource = {
subtitleNewTab: 'Επιστροφή στη νέα καρτέλα που άνοιξε για να συνεχίσετε',
titleNewTab: 'Έχετε συνδεθεί σε άλλη καρτέλα',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Χρησιμοποιήστε τον σύνδεσμο επαλήθευσης που στάλθηκε στο email σας',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/en-GB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,10 @@ export const enGB: LocalizationResource = {
subtitleNewTab: 'Return to the newly opened tab to continue',
titleNewTab: 'Signed in on other tab',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Use the verification link sent to your email',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,10 @@ export const enUS: LocalizationResource = {
subtitleNewTab: 'Return to the newly opened tab to continue',
titleNewTab: 'Signed in on other tab',
},
verifiedTransferable: {
subtitle: 'Return to original tab to continue',
title: 'Email verified',
},
},
emailLinkMfa: {
formSubtitle: 'Use the verification link sent to your email',
Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/es-CR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,10 @@ export const esCR: LocalizationResource = {
subtitleNewTab: 'Regresa a la pestaña recién abierta para continuar',
titleNewTab: 'Sesión iniciada en otra pestaña',
},
verifiedTransferable: {
subtitle: undefined,
title: undefined,
},
},
emailLinkMfa: {
formSubtitle: 'Utiliza el enlace de verificación enviado a tu correo electrónico',
Expand Down
Loading
Loading