diff --git a/.changeset/protect-check-modal.md b/.changeset/protect-check-modal.md
new file mode 100644
index 00000000000..df108002442
--- /dev/null
+++ b/.changeset/protect-check-modal.md
@@ -0,0 +1,10 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/ui': minor
+'@clerk/shared': patch
+'@clerk/react': patch
+---
+
+Resolve Clerk Protect challenges in custom flows built with the `useSignIn()` and `useSignUp()` hooks. When a sign-in or sign-up method returns a Protect challenge, Clerk opens a modal over the page, runs the challenge, and returns from the method once it clears. Your flow doesn't need to render anything or call `submitProtectCheck()`. `` also resolves a challenge that arrives with an SSO redirect before it continues the flow. If Clerk's UI isn't loaded, these methods return with `protectCheck` set, as before.
+
+The prebuilt `` and `` components are unchanged and still run challenges inside their own cards. Classic resource methods such as `clerk.client.signIn.create()` are also unchanged. They return with `protectCheck` set, and the caller runs the challenge.
diff --git a/integration/tests/protect-check.test.ts b/integration/tests/protect-check.test.ts
index 4e230ff9dee..9874be08247 100644
--- a/integration/tests/protect-check.test.ts
+++ b/integration/tests/protect-check.test.ts
@@ -12,6 +12,13 @@ const waitForProtectCheckSubmit = (page: Page) =>
{ timeout: 30_000 },
);
+const protectCheckModalSelector = '.cl-modalContent:has(.cl-protectCheck-root)';
+
+const waitForProtectCheckModal = (page: Page) =>
+ page.waitForFunction(selector => !!document.querySelector(selector), protectCheckModalSelector, {
+ timeout: 30_000,
+ });
+
test.describe('protect check @generic', () => {
test.describe.configure({ mode: 'serial' });
@@ -41,8 +48,10 @@ test.describe('protect check @generic', () => {
const protectCheckSubmit = waitForProtectCheckSubmit(page);
await u.po.signUp.goTo();
+ const protectCheckRoute = page.waitForURL(/protect-check/, { timeout: 30_000 });
await u.po.signUp.signUpWithEmailAndPassword({ email: fakeUser.email!, password: fakeUser.password });
+ await protectCheckRoute;
expect((await protectCheckSubmit).ok()).toBe(true);
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
@@ -55,14 +64,86 @@ test.describe('protect check @generic', () => {
const protectCheckSubmit = waitForProtectCheckSubmit(page);
await u.po.signIn.goTo();
+ const protectCheckRoute = page.waitForURL(/protect-check/, { timeout: 30_000 });
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeUser.email!,
password: fakeUser.password,
waitForSession: false,
});
+ await protectCheckRoute;
expect((await protectCheckSubmit).ok()).toBe(true);
await u.po.signIn.enterTestOtpCode();
await u.po.expect.toBeSignedIn();
});
});
+
+test.describe('protect check in custom flows @custom', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let app: Application;
+ let fakeUser: FakeUser | undefined;
+
+ test.beforeAll(async () => {
+ test.setTimeout(150_000);
+ app = await appConfigs.customFlows.reactVite.clone().commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withProtectService);
+ await app.dev();
+ });
+
+ test.afterEach(async () => {
+ await fakeUser?.deleteIfExists();
+ fakeUser = undefined;
+ });
+
+ test.afterAll(async () => {
+ await app.teardown();
+ });
+
+ test('shows the Protect modal on sign-up', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ fakeUser = u.services.users.createFakeUser(test);
+ const protectCheckSubmit = waitForProtectCheckSubmit(page);
+ const prepareVerification = page.waitForResponse(
+ response => response.request().method() === 'POST' && response.url().includes('prepare_verification'),
+ { timeout: 30_000 },
+ );
+
+ await u.page.goToRelative('/sign-up');
+ await expect(u.page.getByText('Sign up', { exact: true })).toBeVisible();
+ const protectCheckModal = waitForProtectCheckModal(page);
+ await u.po.signUp.signUp({ email: fakeUser.email!, password: fakeUser.password });
+
+ expect((await protectCheckSubmit).ok()).toBe(true);
+ await protectCheckModal;
+ await page.locator(protectCheckModalSelector).waitFor({ state: 'detached' });
+ await prepareVerification;
+ await u.page.getByRole('textbox', { name: 'code' }).fill('424242');
+ await u.po.signUp.continue();
+ await u.page.waitForURL(/protected/);
+ await u.po.expect.toBeSignedIn();
+ });
+
+ test('shows the Protect modal on sign-in', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ fakeUser = u.services.users.createFakeUser(test);
+ await u.services.users.createBapiUser(fakeUser);
+ const protectCheckSubmit = waitForProtectCheckSubmit(page);
+
+ await u.page.goToRelative('/sign-in');
+ await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
+ const protectCheckModal = waitForProtectCheckModal(page);
+ await u.po.signIn.setIdentifier(fakeUser.email!);
+ await u.po.signIn.continue();
+
+ expect((await protectCheckSubmit).ok()).toBe(true);
+ await protectCheckModal;
+ await page.locator(protectCheckModalSelector).waitFor({ state: 'detached' });
+ await u.page.getByRole('button', { name: 'email_code', exact: true }).click();
+ await u.page.getByRole('textbox', { name: 'code' }).fill('424242');
+ await u.po.signIn.continue();
+ await u.page.waitForURL(/protected/);
+ await u.po.expect.toBeSignedIn();
+ });
+});
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index a421c0f931c..1bbefe91488 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": "81KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "124.5KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" },
- { "path": "./dist/clerk.native.js", "maxSize": "80KB" },
+ { "path": "./dist/clerk.native.js", "maxSize": "82KB" },
{ "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/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts
index 0607dc183c4..0a2a7f40aaa 100644
--- a/packages/clerk-js/src/core/__tests__/clerk.test.ts
+++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts
@@ -18,6 +18,7 @@ import { restoreDocument, setDocumentVisibilityState } from '@/test/document-hel
import { mockNativeRuntime } from '../../test/utils';
import { Clerk } from '../clerk';
import { eventBus, events } from '../events';
+import { ProtectCheckGate } from '../protectCheckGate';
import type { DisplayConfig, Organization } from '../resources/internal';
import { BaseResource, Client, Environment, SignIn, SignUp } from '../resources/internal';
@@ -4005,6 +4006,107 @@ describe('Clerk singleton', () => {
});
});
+ describe('protect check modal', () => {
+ beforeEach(() => {
+ mockEnvironmentFetch.mockReturnValue(
+ Promise.resolve({
+ userSettings: mockUserSettings,
+ displayConfig: mockDisplayConfig,
+ isSingleSession: () => false,
+ isProduction: () => true,
+ isDevelopmentOrStaging: () => false,
+ }),
+ );
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [],
+ }),
+ );
+ });
+
+ const gatedSignIn = () => ({
+ protectCheck: { status: 'pending', token: 'tok', sdkUrl: 'https://p.example.com/sdk.js' },
+ });
+
+ it('resolves at once when Clerk was loaded without UI components', async () => {
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+
+ await expect(sut.__internal_openProtectCheckModal({ resource: gatedSignIn() as any })).resolves.toBeUndefined();
+ });
+
+ it('resolves at once and leaves the gate when the UI predates the Protect modal', async () => {
+ const openModal = vi.fn();
+ const mockClerkUICtor = vi.fn(function () {
+ return { ensureMounted: () => Promise.resolve({ openModal, closeModal: vi.fn() }) };
+ });
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load({ ...mockedLoadOptions, ui: { ClerkUI: mockClerkUICtor } });
+ const resource = gatedSignIn() as any;
+
+ await expect(sut.__internal_openProtectCheckModal({ resource })).resolves.toBeUndefined();
+ expect(openModal).not.toHaveBeenCalled();
+ expect(resource.protectCheck).not.toBeNull();
+ });
+
+ it('closes the modal and rejects with the error the modal reports', async () => {
+ const openProtectCheckModal = vi.fn();
+ const closeModal = vi.fn();
+ const mockClerkUICtor = vi.fn(function () {
+ return { ensureMounted: () => Promise.resolve({ openProtectCheckModal, closeModal }) };
+ });
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load({ ...mockedLoadOptions, ui: { ClerkUI: mockClerkUICtor } });
+ const blocked = new Error('blocked');
+
+ const pending = sut.__internal_openProtectCheckModal({ resource: gatedSignIn() as any });
+ await vi.waitFor(() => expect(openProtectCheckModal).toHaveBeenCalled());
+ openProtectCheckModal.mock.calls[0][0].onFailed(blocked);
+
+ await expect(pending).rejects.toBe(blocked);
+ expect(closeModal).toHaveBeenCalledWith('protectCheck');
+ });
+
+ const loadWithClient = async (client: { signIn: Record; signUp: Record }) => {
+ mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [], ...client }));
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+ return sut;
+ };
+
+ it('resolves gates the client carries on its sign-in and sign-up', async () => {
+ const resolve = vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
+ const sut = await loadWithClient({ signIn: gatedSignIn(), signUp: gatedSignIn() });
+
+ await sut.__internal_resolvePendingProtectCheck();
+
+ expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signIn);
+ expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signUp);
+ resolve.mockRestore();
+ });
+
+ it('leaves every gate alone once the sign-in is complete', async () => {
+ const resolve = vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
+ const sut = await loadWithClient({ signIn: { status: 'complete' }, signUp: gatedSignIn() });
+
+ await sut.__internal_resolvePendingProtectCheck();
+
+ expect(resolve).not.toHaveBeenCalled();
+ resolve.mockRestore();
+ });
+
+ it('resolves only the sign-up gate when the callback is a sign-up', async () => {
+ const resolve = vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
+ const sut = await loadWithClient({ signIn: gatedSignIn(), signUp: gatedSignIn() });
+
+ await sut.__internal_resolvePendingProtectCheck('signUp');
+
+ expect(resolve).toHaveBeenCalledTimes(1);
+ expect(resolve).toHaveBeenCalledWith(sut, sut.client?.signUp);
+ resolve.mockRestore();
+ });
+ });
+
describe('ui.ClerkUI option', () => {
beforeEach(() => {
mockEnvironmentFetch.mockReturnValue(
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 081c9665f59..f5eaa05aa92 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -62,6 +62,7 @@ import type {
__internal_EnableOrganizationsPromptProps,
__internal_OAuthConsentProps,
__internal_PlanDetailsProps,
+ __internal_ProtectCheckModalProps,
__internal_SubscriptionDetailsProps,
__internal_UserVerificationModalProps,
APIKeysNamespace,
@@ -108,6 +109,7 @@ import type {
OrganizationSwitcherProps,
PricingTableProps,
ProtectAssertion,
+ ProtectCheckFlow,
PublicKeyCredentialCreationOptionsWithoutExtensions,
PublicKeyCredentialRequestOptionsWithoutExtensions,
PublicKeyCredentialWithAuthenticatorAssertionResponse,
@@ -195,6 +197,7 @@ import { createCheckoutInstance } from './modules/checkout/instance';
import { OAuthApplication } from './modules/oauthApplication';
import { Protect } from './protect';
import { protectAssertionParams } from './protectAssertion';
+import { ProtectCheckGate } from './protectCheckGate';
import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal';
import { State } from './state';
@@ -991,6 +994,48 @@ export class Clerk implements ClerkInterface {
.then(controls => controls.closeModal('enableOrganizationsPrompt'));
};
+ public __internal_resolvePendingProtectCheck = async (flow?: ProtectCheckFlow): Promise => {
+ const client = this.client;
+ if (!client || client.signIn.status === 'complete' || client.signUp.status === 'complete') {
+ return;
+ }
+ const gate = ProtectCheckGate.getInstance();
+ if (flow !== 'signUp') {
+ await gate.resolve(this, client.signIn);
+ }
+ if (flow !== 'signIn') {
+ await gate.resolve(this, client.signUp);
+ }
+ };
+
+ public __internal_openProtectCheckModal = (
+ props: Pick<__internal_ProtectCheckModalProps, 'resource'>,
+ ): Promise => {
+ if (!this.#clerkUI) {
+ return Promise.resolve();
+ }
+ return this.#clerkUI
+ .then(ui => ui.ensureMounted())
+ .then(controls => {
+ if (!controls.openProtectCheckModal) {
+ return;
+ }
+ return new Promise((resolve, reject) => {
+ controls.openProtectCheckModal?.({
+ ...props,
+ onResolved: () => {
+ controls.closeModal('protectCheck');
+ resolve();
+ },
+ onFailed: error => {
+ controls.closeModal('protectCheck');
+ reject(error);
+ },
+ });
+ });
+ });
+ };
+
public __internal_openBlankCaptchaModal = (): Promise => {
this.assertComponentsReady(this.#clerkUI);
return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.openModal('blankCaptcha', {}));
diff --git a/packages/clerk-js/src/core/protectCheckGate.test.ts b/packages/clerk-js/src/core/protectCheckGate.test.ts
new file mode 100644
index 00000000000..2918431eb60
--- /dev/null
+++ b/packages/clerk-js/src/core/protectCheckGate.test.ts
@@ -0,0 +1,111 @@
+import { createDeferredPromise } from '@clerk/shared/utils';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ProtectCheckGate } from './protectCheckGate';
+import type { Clerk } from './resources/internal';
+
+const gated = (id = 'sia_1') =>
+ ({
+ id,
+ protectCheck: { status: 'pending', token: 'tok', sdkUrl: 'https://protect.example.com/sdk.js' },
+ }) as any;
+
+const mockClerk = (overrides: Partial = {}) =>
+ ({
+ __internal_openProtectCheckModal: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ }) as unknown as Clerk;
+
+describe('ProtectCheckGate', () => {
+ let gate: ProtectCheckGate;
+
+ beforeEach(() => {
+ gate = ProtectCheckGate.getInstance();
+ });
+
+ it('opens the modal for a gated resource and waits for it to resolve', async () => {
+ const deferred = createDeferredPromise();
+ const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) });
+ const resource = gated();
+
+ let settled = false;
+ const pending = gate.resolve(clerk, resource).then(() => {
+ settled = true;
+ });
+ await Promise.resolve();
+ expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledWith({ resource });
+ expect(settled).toBe(false);
+
+ deferred.resolve();
+ await pending;
+ expect(settled).toBe(true);
+ });
+
+ it('leaves the proof submission to whatever runs the challenge', async () => {
+ const clerk = mockClerk();
+
+ await gate.resolve(clerk, gated(), 'protect_check');
+
+ expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled();
+ });
+
+ it('makes a call on the same resource share the in-flight resolution', async () => {
+ const deferred = createDeferredPromise();
+ const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) });
+
+ const outer = gate.resolve(clerk, gated());
+ let innerSettled = false;
+ const inner = gate.resolve(clerk, gated()).then(() => {
+ innerSettled = true;
+ });
+ await Promise.resolve();
+ expect(innerSettled).toBe(false);
+
+ deferred.resolve();
+ await Promise.all([outer, inner]);
+ expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1);
+ });
+
+ it('makes a call on another resource wait, then resolve its own gate', async () => {
+ const first = createDeferredPromise();
+ const open = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValueOnce(undefined);
+ const clerk = mockClerk({ __internal_openProtectCheckModal: open });
+ const signUp = gated('sua_1');
+
+ const outer = gate.resolve(clerk, gated('sia_1'));
+ const other = gate.resolve(clerk, signUp);
+ await Promise.resolve();
+ expect(open).toHaveBeenCalledTimes(1);
+
+ first.resolve();
+ await Promise.all([outer, other]);
+ expect(open).toHaveBeenCalledTimes(2);
+ expect(open).toHaveBeenLastCalledWith({ resource: signUp });
+ });
+
+ it('lets a call on a resource without a gate return while another resource holds the modal', async () => {
+ const deferred = createDeferredPromise();
+ const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) });
+
+ const outer = gate.resolve(clerk, gated('sia_1'));
+ let clearSettled = false;
+ const clear = gate.resolve(clerk, { id: 'sua_1', protectCheck: null } as any).then(() => {
+ clearSettled = true;
+ });
+ await Promise.resolve();
+ const settledWhileModalOpen = clearSettled;
+
+ deferred.resolve();
+ await Promise.all([outer, clear]);
+ expect(settledWhileModalOpen).toBe(true);
+ });
+
+ it('releases the in-flight lock and rethrows when the modal cannot open', async () => {
+ const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockRejectedValue(new Error('no ui')) });
+ await expect(gate.resolve(clerk, gated())).rejects.toThrow('no ui');
+
+ const next = mockClerk();
+ await gate.resolve(next, gated());
+ expect(next.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/clerk-js/src/core/protectCheckGate.ts b/packages/clerk-js/src/core/protectCheckGate.ts
new file mode 100644
index 00000000000..13c35cec014
--- /dev/null
+++ b/packages/clerk-js/src/core/protectCheckGate.ts
@@ -0,0 +1,43 @@
+import type { SignInResource, SignUpResource } from '@clerk/shared/types';
+
+import type { Clerk } from './resources/internal';
+
+/**
+ * Resolves a pending `protect_check` on a sign-in or sign-up resource by opening Clerk's Protect
+ * modal and waiting for the challenge to clear. One resolution runs at a time. A call on the same
+ * resource shares it, and a call on another resource waits for it before resolving its own gate.
+ * Skips the proof submission, which belongs to whatever runs the challenge, and no-RHC builds where
+ * the challenge script must not be loaded.
+ */
+export class ProtectCheckGate {
+ private static instance: ProtectCheckGate;
+
+ private inflight: { resourceId: string | undefined; promise: Promise } | null = null;
+
+ public static getInstance(): ProtectCheckGate {
+ if (!ProtectCheckGate.instance) {
+ ProtectCheckGate.instance = new ProtectCheckGate();
+ }
+ return ProtectCheckGate.instance;
+ }
+
+ public async resolve(clerk: Clerk, resource: SignInResource | SignUpResource, action?: string): Promise {
+ if (__BUILD_DISABLE_RHC__ || action === 'protect_check' || !resource.protectCheck) {
+ return;
+ }
+ while (this.inflight) {
+ if (this.inflight.resourceId === resource.id) {
+ return this.inflight.promise;
+ }
+ await this.inflight.promise.catch(() => {});
+ }
+ if (!resource.protectCheck) {
+ return;
+ }
+ const promise = clerk.__internal_openProtectCheckModal({ resource }).finally(() => {
+ this.inflight = null;
+ });
+ this.inflight = { resourceId: resource.id, promise };
+ await promise;
+ }
+}
diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts
index ebbaebeae07..c89e18b2c6b 100644
--- a/packages/clerk-js/src/core/resources/SignIn.ts
+++ b/packages/clerk-js/src/core/resources/SignIn.ts
@@ -100,6 +100,7 @@ import {
clerkVerifyWeb3WalletCalledBeforeCreate,
} from '../errors';
import { eventBus } from '../events';
+import { ProtectCheckGate } from '../protectCheckGate';
import { BaseResource, UserData, Verification } from './internal';
/**
@@ -168,7 +169,11 @@ export class SignIn extends BaseResource implements SignInResource {
* This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance
* of `SignIn`.
*/
- __internal_basePost = this._basePost.bind(this);
+ __internal_basePost: typeof this._basePost = async params => {
+ await this._basePost(params);
+ await ProtectCheckGate.getInstance().resolve(SignIn.clerk, this, params?.action);
+ return this;
+ };
/**
* @internal Only used for internal purposes, and is not intended to be used directly.
@@ -176,7 +181,11 @@ export class SignIn extends BaseResource implements SignInResource {
* This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance
* of `SignIn`.
*/
- __internal_basePatch = this._basePatch.bind(this);
+ __internal_basePatch: typeof this._basePatch = async params => {
+ await this._basePatch(params);
+ await ProtectCheckGate.getInstance().resolve(SignIn.clerk, this, params?.action);
+ return this;
+ };
/**
* @internal Only used for internal purposes, and is not intended to be used directly.
diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts
index ac8c1d0b5c9..2722f968d87 100644
--- a/packages/clerk-js/src/core/resources/SignUp.ts
+++ b/packages/clerk-js/src/core/resources/SignUp.ts
@@ -69,6 +69,7 @@ import {
clerkVerifyWeb3WalletCalledBeforeCreate,
} from '../errors';
import { eventBus } from '../events';
+import { ProtectCheckGate } from '../protectCheckGate';
import { BaseResource, SignUpVerifications } from './internal';
declare global {
@@ -142,7 +143,11 @@ export class SignUp extends BaseResource implements SignUpResource {
* This property is used to provide access to underlying Client methods to `SignUpFuture`, which wraps an instance
* of `SignUp`.
*/
- __internal_basePost = this._basePost.bind(this);
+ __internal_basePost: typeof this._basePost = async params => {
+ await this._basePost(params);
+ await ProtectCheckGate.getInstance().resolve(SignUp.clerk, this, params?.action);
+ return this;
+ };
/**
* @internal Only used for internal purposes, and is not intended to be used directly.
@@ -150,7 +155,11 @@ export class SignUp extends BaseResource implements SignUpResource {
* This property is used to provide access to underlying Client methods to `SignUpFuture`, which wraps an instance
* of `SignUp`.
*/
- __internal_basePatch = this._basePatch.bind(this);
+ __internal_basePatch: typeof this._basePatch = async params => {
+ await this._basePatch(params);
+ await ProtectCheckGate.getInstance().resolve(SignUp.clerk, this, params?.action);
+ return this;
+ };
constructor(data: SignUpJSON | SignUpJSONSnapshot | null = null) {
super();
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 2af05b64bbd..b78765f088e 100644
--- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
@@ -2,6 +2,7 @@ import { createDeferredPromise } from '@clerk/shared/utils';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../../events';
+import { ProtectCheckGate } from '../../protectCheckGate';
import { signInErrorSignal, signInResourceSignal } from '../../signals';
import { BaseResource } from '../internal';
import { SignIn } from '../SignIn';
@@ -3674,3 +3675,43 @@ describe('SignIn', () => {
});
});
});
+
+describe('SignIn protect_check gate', () => {
+ let previousClerk: any;
+
+ beforeEach(() => {
+ previousClerk = SignIn.clerk;
+ SignIn.clerk = {} as any;
+ vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
+ BaseResource._fetch = vi.fn().mockResolvedValue({
+ client: null,
+ response: {
+ id: 'signin_123',
+ status: 'needs_protect_check',
+ protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' },
+ },
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ SignIn.clerk = previousClerk;
+ });
+
+ it('hands the resource to the gate after a Future call', async () => {
+ const signIn = new SignIn();
+
+ await signIn.__internal_future.create({ identifier: 'user@example.com' });
+
+ expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(SignIn.clerk, signIn, undefined);
+ });
+
+ it('returns a classic call with the gate still pending', async () => {
+ const signIn = new SignIn();
+
+ await signIn.create({ identifier: 'user@example.com' });
+
+ expect(signIn.protectCheck?.token).toBe('challenge-token');
+ expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
index dd2a5617b47..f5a4bfbd89a 100644
--- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
@@ -2,6 +2,7 @@ import { createDeferredPromise } from '@clerk/shared/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '../../events';
+import { ProtectCheckGate } from '../../protectCheckGate';
import { signUpErrorSignal, signUpResourceSignal } from '../../signals';
import { BaseResource } from '../internal';
import { SignUp } from '../SignUp';
@@ -2334,3 +2335,43 @@ describe('SignUp', () => {
});
});
});
+
+describe('SignUp protect_check gate', () => {
+ let previousClerk: any;
+
+ beforeEach(() => {
+ previousClerk = SignUp.clerk;
+ SignUp.clerk = { __internal_environment: { displayConfig: { captchaOauthBypass: [] } } } as any;
+ vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined);
+ BaseResource._fetch = vi.fn().mockResolvedValue({
+ client: null,
+ response: {
+ id: 'signup_123',
+ status: 'needs_protect_check',
+ protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' },
+ },
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ SignUp.clerk = previousClerk;
+ });
+
+ it('hands the resource to the gate after a Future call', async () => {
+ const signUp = new SignUp();
+
+ await signUp.__internal_future.create({ emailAddress: 'user@example.com' });
+
+ expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(SignUp.clerk, signUp, undefined);
+ });
+
+ it('returns a classic call with the gate still pending', async () => {
+ const signUp = new SignUp();
+
+ await signUp.create({ emailAddress: 'user@example.com' });
+
+ expect(signUp.protectCheck?.token).toBe('challenge-token');
+ expect(ProtectCheckGate.prototype.resolve).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx
index cc3e13ff439..261c94fed8e 100644
--- a/packages/react/src/components/HandleSSOCallback.tsx
+++ b/packages/react/src/components/HandleSSOCallback.tsx
@@ -78,6 +78,14 @@ export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode {
// Prevent re-running this effect if the page is re-rendered during session activation (such as on Next.js).
hasRun.current = true;
+ const intent = new URLSearchParams(window.location.search).get('intent');
+ const flow = intent === 'signIn' || intent === 'signUp' ? intent : undefined;
+ try {
+ await clerk.__internal_resolvePendingProtectCheck?.(flow);
+ } catch {
+ return navigateToSignIn();
+ }
+
// If this was a sign-in, and it's complete, there's nothing else to do.
// Note: We perform a cast here to prevent TypeScript from narrowing the type of signIn.status. TypeScript
// doesn't understand that the status can be mutated during the execution of this function.
diff --git a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx
index 7eb4622f745..1a29782611c 100644
--- a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx
+++ b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx
@@ -18,6 +18,7 @@ const mockSignUpFinalize = vi.fn().mockImplementation(async ({ navigate }) => {
return { error: null };
});
const mockSignUpCreate = vi.fn().mockResolvedValue({ error: null });
+const mockResolvePendingProtectCheck = vi.fn().mockResolvedValue(undefined);
const mockSetActive = vi.fn().mockImplementation(async ({ navigate }) => {
await navigate({ session: { id: 'sess_existing' }, decorateUrl: (url: string) => url });
});
@@ -30,6 +31,7 @@ vi.mock('../../../src/hooks', () => ({
useClerk: () => ({
loaded: mockClerkLoaded,
setActive: mockSetActive,
+ __internal_resolvePendingProtectCheck: mockResolvePendingProtectCheck,
}),
useSignIn: () => ({
signIn: {
@@ -82,6 +84,7 @@ describe('', () => {
mockClerkLoaded = true;
mockSignIn = {};
mockSignUp = {};
+ mockResolvePendingProtectCheck.mockResolvedValue(undefined);
});
it('renders captcha element by default', () => {
@@ -116,6 +119,69 @@ describe('', () => {
});
});
+ it('resolves a Protect gate carried by the client before routing', async () => {
+ mockSignIn = { status: 'needs_protect_check' };
+ mockResolvePendingProtectCheck.mockImplementation(async () => {
+ mockSignIn = { status: 'complete' };
+ });
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockSignInFinalize).toHaveBeenCalled();
+ });
+ expect(mockResolvePendingProtectCheck).toHaveBeenCalledTimes(1);
+ expect(mockResolvePendingProtectCheck.mock.invocationCallOrder[0]).toBeLessThan(
+ mockSignInFinalize.mock.invocationCallOrder[0],
+ );
+ });
+
+ it('scopes the Protect gate to the intent carried by the callback URL', async () => {
+ const href = window.location.href;
+ window.history.replaceState(null, '', '/sso-callback?intent=signUp');
+ mockSignUp = { status: 'missing_requirements' };
+
+ try {
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockResolvePendingProtectCheck).toHaveBeenCalledWith('signUp');
+ });
+ } finally {
+ window.history.replaceState(null, '', href);
+ }
+ });
+
+ it('navigates to sign-in when Protect blocks the attempt', async () => {
+ mockSignIn = { status: 'needs_protect_check' };
+ mockResolvePendingProtectCheck.mockRejectedValue(new Error('blocked'));
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockNavigateToSignIn).toHaveBeenCalled();
+ });
+ expect(mockNavigateToApp).not.toHaveBeenCalled();
+ });
+
it('finalizes sign-in and navigates to app when signIn.status is complete', async () => {
mockSignIn = { status: 'complete' };
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index 1bceeeacdb3..6b972ec0fdd 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -48,6 +48,7 @@ import type {
OrganizationSwitcherProps,
PricingTableProps,
ProtectAssertion,
+ ProtectCheckFlow,
RedirectOptions,
Resources,
ResumeAfterProtectCheckParams,
@@ -1649,6 +1650,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
}
};
+ __internal_resolvePendingProtectCheck = async (flow?: ProtectCheckFlow): Promise => {
+ await this.clerkjs?.__internal_resolvePendingProtectCheck?.(flow);
+ };
+
__internal_resumeAfterProtectCheck = async (
params?: ResumeAfterProtectCheckParams,
customNavigate?: (to: string) => Promise,
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index 5c09303315b..ec23d03de7a 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -342,6 +342,17 @@ export interface Clerk {
*/
__internal_protectChallengeLoadTimeoutMs?: number;
+ /**
+ * Resolves a pending `protect_check` on the client's current sign-in or sign-up through Clerk's
+ * Protect modal. Callback pages need it because the gate arrives with the client, not on a request.
+ * Does nothing once either attempt is `complete`. Pass `flow` to resolve only that attempt's gate,
+ * so a callback for one flow does not open the challenge of a stale attempt on the other. Without
+ * `flow`, the sign-in gate is resolved before the sign-up gate.
+ *
+ * @internal
+ */
+ __internal_resolvePendingProtectCheck?: (flow?: ProtectCheckFlow) => Promise;
+
frontendApi: string;
/** Your Clerk [Publishable Key](!publishable-key). */
@@ -1979,6 +1990,14 @@ export type __internal_UserVerificationProps = RoutingOptions & {
export type __internal_UserVerificationModalProps = WithoutRouting<__internal_UserVerificationProps>;
+export type ProtectCheckFlow = 'signIn' | 'signUp';
+
+export type __internal_ProtectCheckModalProps = {
+ resource: SignInResource | SignUpResource;
+ onResolved: () => void;
+ onFailed: (error: unknown) => void;
+};
+
export type __internal_EnableOrganizationsPromptProps = {
onSuccess?: () => void;
onClose?: () => void;
diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts
index b8186f111ba..1f1ee50301e 100644
--- a/packages/shared/src/types/signInFuture.ts
+++ b/packages/shared/src/types/signInFuture.ts
@@ -364,7 +364,7 @@ export interface SignInFutureResource {
* `'needs_first_factor'` - One of the following [first factor verification](!first-factor-verification) strategies is missing: `'email_link'`, `'email_code'`, `passkey`, `password`, `'phone_code'`, `'web3_base_signature'`, `'web3_metamask_signature'`, `'web3_coinbase_wallet_signature'`, `'web3_okx_wallet_signature'`, `'web3_solana_signature'`, [`OAuthStrategy`](https://clerk.com/docs/reference/types/sso#oauthstrategy), or `'enterprise_sso'`.
* `'needs_second_factor'` - One of the following [second factor verification](!second-factor-verification) strategies is missing: `'phone_code'`, `'totp'`, `'backup_code'`, `'email_code'`, or `'email_link'`.
* `'needs_new_password'` - The user needs to set a new password. See the [dedicated custom flow](/docs/guides/development/custom-flows/authentication/forgot-password) guide for more information.
- * `'needs_protect_check'` - A Clerk Protect challenge must be resolved before the sign-in can continue. This status is only returned when Protect mid-flow challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. Run the challenge described by `protectCheck` and resolve it via `submitProtectCheck()`. The pre-built components handle this automatically.
+ * `'needs_protect_check'` - A Clerk Protect challenge must be resolved before the sign-in can continue. This status is only returned when Protect mid-flow challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it. When Clerk's UI is loaded, the sign-in methods resolve the challenge in a modal before they return. Otherwise, run the challenge described by `protectCheck` and resolve it via `submitProtectCheck()`. The pre-built components handle this automatically.
*
*/
readonly status: SignInStatus;
@@ -423,6 +423,8 @@ export interface SignInFutureResource {
/**
* The current protect check challenge, if one is pending. Only populated when Protect mid-flow
* challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it.
+ * When Clerk's UI is loaded, the sign-in methods resolve the challenge in a modal before they
+ * return. Otherwise, run the challenge yourself and submit its proof token with `submitProtectCheck()`.
*/
readonly protectCheck: ProtectCheckResource | null;
@@ -599,7 +601,7 @@ export interface SignInFutureResource {
passkey: (params?: SignInFuturePasskeyParams) => Promise<{ error: ClerkError | null }>;
/**
- * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively.
+ * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. Call it after running the challenge yourself when Clerk's UI isn't loaded. With the UI loaded, the other sign-in methods resolve the challenge in a modal before they return.
*/
submitProtectCheck: (params: SignInFutureSubmitProtectCheckParams) => Promise<{ error: ClerkError | null }>;
diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts
index 31a341eb795..24ab5f23604 100644
--- a/packages/shared/src/types/signUpFuture.ts
+++ b/packages/shared/src/types/signUpFuture.ts
@@ -481,6 +481,8 @@ export interface SignUpFutureResource {
/**
* The current protect check challenge, if one is pending. Only populated when Protect mid-flow
* challenges are explicitly enabled for the instance; upgrading the SDK alone does not enable it.
+ * When Clerk's UI is loaded, the sign-up methods resolve the challenge in a modal before they
+ * return. Otherwise, run the challenge yourself and submit its proof token with `submitProtectCheck()`.
*/
readonly protectCheck: ProtectCheckResource | null;
@@ -535,7 +537,7 @@ export interface SignUpFutureResource {
web3: (params: SignUpFutureWeb3Params) => Promise<{ error: ClerkError | null }>;
/**
- * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively.
+ * Submits a proof token to resolve a pending protect check challenge. The response may contain another `protectCheck` (a chained challenge) which must be resolved iteratively. Call it after running the challenge yourself when Clerk's UI isn't loaded. With the UI loaded, the other sign-up methods resolve the challenge in a modal before they return.
*/
submitProtectCheck: (params: SignUpFutureSubmitProtectCheckParams) => Promise<{ error: ClerkError | null }>;
diff --git a/packages/shared/src/ui/types.ts b/packages/shared/src/ui/types.ts
index 820958ce1b8..a2ed6d89f05 100644
--- a/packages/shared/src/ui/types.ts
+++ b/packages/shared/src/ui/types.ts
@@ -1,5 +1,5 @@
import type { ModuleManager } from '../moduleManager';
-import type { Clerk, ClerkOptions, EnvironmentResource } from '../types';
+import type { __internal_ProtectCheckModalProps, Clerk, ClerkOptions, EnvironmentResource } from '../types';
// TODO @nikos
type Appearance = any;
@@ -21,6 +21,7 @@ export type ComponentControls = {
closeDrawer: (drawer: string, options?: { notify?: boolean }) => void;
prefetch: (component: 'organizationSwitcher') => void;
mountImpersonationFab: () => void;
+ openProtectCheckModal?: (props: __internal_ProtectCheckModalProps) => void;
};
// Instance shape that the class will implement
diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx
index ef830af901b..0fb3e678246 100644
--- a/packages/ui/src/Components.tsx
+++ b/packages/ui/src/Components.tsx
@@ -4,6 +4,7 @@ import type {
__internal_CheckoutProps,
__internal_EnableOrganizationsPromptProps,
__internal_PlanDetailsProps,
+ __internal_ProtectCheckModalProps,
__internal_SubscriptionDetailsProps,
__internal_UserVerificationProps,
Clerk,
@@ -39,6 +40,7 @@ import {
KeylessPrompt,
OrganizationProfileModal,
preloadComponent,
+ ProtectCheckModal,
SignInModal,
SignUpModal,
UserProfileModal,
@@ -97,6 +99,7 @@ export type ComponentControls = {
| 'userVerification'
| 'waitlist'
| 'blankCaptcha'
+ | 'protectCheck'
| 'enableOrganizationsPrompt',
>(
modal: T,
@@ -112,7 +115,9 @@ export type ComponentControls = {
? InviteMembersModalProps
: T extends 'enableOrganizationsPrompt'
? __internal_EnableOrganizationsPromptProps
- : UserProfileProps,
+ : T extends 'protectCheck'
+ ? __internal_ProtectCheckModalProps
+ : UserProfileProps,
) => void;
closeModal: (
modal:
@@ -126,6 +131,7 @@ export type ComponentControls = {
| 'userVerification'
| 'waitlist'
| 'blankCaptcha'
+ | 'protectCheck'
| 'enableOrganizationsPrompt',
options?: {
notify?: boolean;
@@ -150,6 +156,7 @@ export type ComponentControls = {
prefetch: (component: 'organizationSwitcher') => void;
// Special case, as the impersonation fab mounts automatically
mountImpersonationFab: () => void;
+ openProtectCheckModal: (props: __internal_ProtectCheckModalProps) => void;
};
interface HtmlNodeOptions {
@@ -180,6 +187,7 @@ interface ComponentsState {
createOrganizationModal: null | CreateOrganizationModalProps;
enableOrganizationsPromptModal: null | __internal_EnableOrganizationsPromptProps;
blankCaptchaModal: null;
+ protectCheckModal: null | __internal_ProtectCheckModalProps;
organizationSwitcherPrefetch: boolean;
waitlistModal: null | WaitlistModalProps;
checkoutDrawer: {
@@ -313,6 +321,7 @@ const Components = (props: ComponentsProps) => {
organizationSwitcherPrefetch: false,
waitlistModal: null,
blankCaptchaModal: null,
+ protectCheckModal: null,
checkoutDrawer: {
open: false,
props: null,
@@ -339,6 +348,7 @@ const Components = (props: ComponentsProps) => {
createOrganizationModal,
waitlistModal,
blankCaptchaModal,
+ protectCheckModal,
checkoutDrawer,
planDetailsDrawer,
subscriptionDetailsDrawer,
@@ -477,6 +487,10 @@ const Components = (props: ComponentsProps) => {
setState(s => ({ ...s, impersonationFab: true }));
};
+ componentsControls.openProtectCheckModal = props => {
+ componentsControls.openModal('protectCheck', props);
+ };
+
componentsControls.openDrawer = (name, props) => {
setState(s => ({
...s,
@@ -686,6 +700,23 @@ const Components = (props: ComponentsProps) => {
);
+ const mountedProtectCheckModal = protectCheckModal && (
+ componentsControls.closeModal('protectCheck')}
+ startPath={buildVirtualRouterUrl({ base: '/protect-check', path: urlStateParam?.path })}
+ componentName={'ProtectCheckModal'}
+ canCloseModal={false}
+ modalContainerSx={t => ({ alignItems: 'center', backdropFilter: `blur(${t.sizes.$2})` })}
+ getContainer={() => null}
+ >
+
+
+ );
+
return (
{
{createOrganizationModal && mountedCreateOrganizationModal}
{waitlistModal && mountedWaitlistModal}
{blankCaptchaModal && mountedBlankCaptchaModal}
+ {mountedProtectCheckModal}
({
+ executeProtectCheck: vi.fn(),
+}));
+
+import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck';
+
+const { createFixtures: createBaseFixtures } = bindCreateFixtures('SignIn');
+
+const createFixtures = async (...args: Parameters) => {
+ const result = await createBaseFixtures(...args);
+ result.fixtures.router.currentPath = '/protect-check';
+ result.fixtures.router.matches.mockImplementation((path?: string) => path === 'protect-check');
+ return result;
+};
+
+const mockExecute = executeProtectCheck as unknown as ReturnType;
+
+beforeEach(() => {
+ mockExecute.mockReset();
+});
+
+describe('ProtectCheckModal', () => {
+ it('calls onResolved once when it mounts for a resource whose gate is already clear', async () => {
+ const { wrapper, fixtures } = await createFixtures();
+ const onResolved = vi.fn();
+ expect(fixtures.signIn.protectCheck).toBeFalsy();
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => expect(onResolved).toHaveBeenCalled());
+ expect(onResolved).toHaveBeenCalledTimes(1);
+ expect(mockExecute).not.toHaveBeenCalled();
+ });
+
+ it('runs a chained challenge and only calls onResolved after the last gate clears', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ const onResolved = vi.fn();
+ mockExecute.mockResolvedValueOnce('proof-1').mockReturnValueOnce(new Promise(() => {}));
+ fixtures.signIn.submitProtectCheck.mockImplementationOnce(() => {
+ (fixtures.signIn as any).protectCheck = {
+ status: 'pending',
+ token: 'challenge-token-2',
+ sdkUrl: 'https://protect.example.com/sdk.js',
+ };
+ return Promise.resolve(fixtures.signIn);
+ });
+
+ render(
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(2));
+ expect(mockExecute.mock.calls[1][0]).toMatchObject({ token: 'challenge-token-2' });
+ expect(onResolved).not.toHaveBeenCalled();
+ });
+
+ it('hands a submit failure to onFailed instead of offering a retry', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ const onResolved = vi.fn();
+ const onFailed = vi.fn();
+ const invalid = new ClerkAPIResponseError('invalid', {
+ status: 422,
+ data: [{ code: 'form_param_invalid', message: 'invalid' } as any],
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockRejectedValue(invalid);
+
+ render(
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => expect(onFailed).toHaveBeenCalledWith(invalid));
+ expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
+ expect(onResolved).not.toHaveBeenCalled();
+ });
+
+ it('hands the timed-out error to onFailed when the challenge is expired and a reload keeps it expired', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck({ expiresAt: Date.now() - 1000 });
+ });
+ const onResolved = vi.fn();
+ const onFailed = vi.fn();
+ const reloadMock = vi.fn().mockResolvedValue(fixtures.signIn);
+ (fixtures.signIn as any).reload = reloadMock;
+
+ render(
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() =>
+ expect(onFailed).toHaveBeenCalledWith(expect.objectContaining({ code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT })),
+ );
+ expect(reloadMock).toHaveBeenCalledTimes(1);
+ expect(mockExecute).not.toHaveBeenCalled();
+ expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
+ expect(onResolved).not.toHaveBeenCalled();
+ });
+
+ it('uses the sign-up localization keys for a sign-up gate', async () => {
+ const { wrapper: Wrapper, fixtures } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck();
+ });
+ mockExecute.mockReturnValue(new Promise(() => {}));
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+
+ {children}
+
+
+ );
+
+ render(
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Sign-up check');
+ expect(screen.queryByText('Sign-in check')).toBeNull();
+ await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/packages/ui/src/components/ProtectCheckModal/index.tsx b/packages/ui/src/components/ProtectCheckModal/index.tsx
new file mode 100644
index 00000000000..61d8cb43958
--- /dev/null
+++ b/packages/ui/src/components/ProtectCheckModal/index.tsx
@@ -0,0 +1,72 @@
+import type { __internal_ProtectCheckModalProps, SignInResource, SignUpResource } from '@clerk/shared/types';
+import { useEffect, useRef } from 'react';
+
+import { withCardStateProvider } from '@/ui/elements/contexts';
+
+import { Flow } from '../../customizables';
+import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
+import { Route, Switch } from '../../router';
+import { ProtectCheckCard } from '../ProtectCheck/ProtectCheckCard';
+
+const flowOf = (resource: SignInResource | SignUpResource) =>
+ resource.pathRoot.endsWith('sign_ups') ? 'signUp' : 'signIn';
+
+const ProtectCheckModalCard = withCardStateProvider(
+ ({ resource, onResolved, onFailed }: __internal_ProtectCheckModalProps) => {
+ const runner = useProtectCheckRunner({
+ getProtectCheck: () => resource.protectCheck,
+ getResource: () => resource,
+ reload: () => resource.reload(),
+ submitProtectCheck: params => resource.submitProtectCheck(params),
+ onResolved: (updated, isCancelled) => {
+ if (!isCancelled() && !updated.protectCheck) {
+ onResolved();
+ }
+ return Promise.resolve();
+ },
+ onError: onFailed,
+ });
+
+ return (
+
+ );
+ },
+);
+
+function ProtectCheckModal(props: __internal_ProtectCheckModalProps): JSX.Element | null {
+ const { resource, onResolved } = props;
+ const isClearOnMount = useRef(!resource.protectCheck).current;
+ const didReportClearRef = useRef(false);
+
+ useEffect(() => {
+ if (isClearOnMount && !didReportClearRef.current) {
+ didReportClearRef.current = true;
+ onResolved();
+ }
+ }, [isClearOnMount, onResolved]);
+
+ if (isClearOnMount) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
+
+ProtectCheckModal.displayName = 'ProtectCheckModal';
+
+export { ProtectCheckModal };
diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx
index 6d13d25476c..a8345aca4a4 100644
--- a/packages/ui/src/elements/contexts/index.tsx
+++ b/packages/ui/src/elements/contexts/index.tsx
@@ -99,6 +99,7 @@ export type FlowMetadata = {
| 'organizationList'
| 'oneTap'
| 'blankCaptcha'
+ | 'protectCheck'
| 'waitlist'
| 'checkout'
| 'planDetails'
diff --git a/packages/ui/src/hooks/useProtectCheckRunner.ts b/packages/ui/src/hooks/useProtectCheckRunner.ts
index 885fcd98f53..22678724080 100644
--- a/packages/ui/src/hooks/useProtectCheckRunner.ts
+++ b/packages/ui/src/hooks/useProtectCheckRunner.ts
@@ -17,6 +17,7 @@ export interface ProtectCheckRunnerParams extends ProtectCheckRunnerR
* `isCancelled` lets the continuation bail if the component unmounted mid-await.
*/
onResolved: (resource: TResource, isCancelled: () => boolean) => Promise;
+ onError?: (error: unknown) => void;
}
export interface ProtectCheckRunnerState {
@@ -54,9 +55,18 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam
const instanceTimeoutMs = useEnvironment().protectConfig?.challenge_load_timeout_ms;
const loadTimeoutMs = loaderTimeoutMs ?? instanceTimeoutMs;
+ // Keep the latest callbacks without re-running the effect when the caller re-renders.
+ const paramsRef = React.useRef(params);
+ paramsRef.current = params;
+
// `handleError` re-throws what it does not recognise, and this runner awaits caller code that
// raises plain errors (a transient fetch failure, an OAuth continuation that did not complete).
const reportError = (err: any) => {
+ const { onError } = paramsRef.current;
+ if (onError) {
+ onError(err);
+ return;
+ }
try {
handleError(err, [], card.setError);
} catch {
@@ -84,10 +94,6 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam
};
}, []);
- // Keep the latest callbacks without re-running the effect when the caller re-renders.
- const paramsRef = React.useRef(params);
- paramsRef.current = params;
-
const reloadCountRef = React.useRef(0);
const token = params.getProtectCheck()?.token;
diff --git a/packages/ui/src/lazyModules/components.ts b/packages/ui/src/lazyModules/components.ts
index 001b6b6eeab..c1f96df66bc 100644
--- a/packages/ui/src/lazyModules/components.ts
+++ b/packages/ui/src/lazyModules/components.ts
@@ -16,6 +16,7 @@ const componentImportPaths = {
ImpersonationFab: () => import(/* webpackChunkName: "impersonationfab" */ './../components/ImpersonationFab'),
GoogleOneTap: () => import(/* webpackChunkName: "onetap" */ './../components/GoogleOneTap'),
BlankCaptchaModal: () => import(/* webpackChunkName: "blankcaptcha" */ './../components/BlankCaptchaModal'),
+ ProtectCheckModal: () => import(/* webpackChunkName: "protectcheck" */ './../components/ProtectCheckModal'),
UserVerification: () => import(/* webpackChunkName: "userverification" */ './../components/UserVerification'),
Waitlist: () => import(/* webpackChunkName: "waitlist" */ './../components/Waitlist'),
KeylessPrompt: () => import(/* webpackChunkName: "keylessPrompt" */ '../components/devPrompts/KeylessPrompt'),
@@ -121,6 +122,10 @@ export const BlankCaptchaModal = lazy(() =>
componentImportPaths.BlankCaptchaModal().then(module => ({ default: module.BlankCaptchaModal })),
);
+export const ProtectCheckModal = lazy(() =>
+ componentImportPaths.ProtectCheckModal().then(module => ({ default: module.ProtectCheckModal })),
+);
+
export const ImpersonationFab = lazy(() =>
componentImportPaths.ImpersonationFab().then(module => ({ default: module.ImpersonationFab })),
);
@@ -203,6 +208,7 @@ export const ClerkComponents = {
Waitlist,
WaitlistModal,
BlankCaptchaModal,
+ ProtectCheckModal,
PricingTable,
Checkout,
PlanDetails,