From 0a096549820c2b3ff62200cefd3412618659c138 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 25 Sep 2026 16:38:00 -0700 Subject: [PATCH 01/12] feat(shared): type the Protect check modal props and handler registration Adds __internal_ProtectCheckModalProps for the modal clerk-js opens on a pending protect_check, and an optional __internal_registerProtectCheckHandler on the Clerk interface so a prebuilt component can claim the gate. --- packages/shared/src/types/clerk.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 5c09303315b..6b4c4d3dc0f 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -342,6 +342,15 @@ export interface Clerk { */ __internal_protectChallengeLoadTimeoutMs?: number; + /** + * Registers a prebuilt component that renders Protect challenges itself, so clerk-js leaves a + * pending `protect_check` on the resource instead of opening its own modal. Returns the + * unregister function. + * + * @internal + */ + __internal_registerProtectCheckHandler?: () => () => void; + frontendApi: string; /** Your Clerk [Publishable Key](!publishable-key). */ @@ -1979,6 +1988,11 @@ export type __internal_UserVerificationProps = RoutingOptions & { export type __internal_UserVerificationModalProps = WithoutRouting<__internal_UserVerificationProps>; +export type __internal_ProtectCheckModalProps = { + resource: SignInResource | SignUpResource; + onResolved: () => void; +}; + export type __internal_EnableOrganizationsPromptProps = { onSuccess?: () => void; onClose?: () => void; From 6ef66918c2e766a7bd36b9f2f9eb20e3af2745f5 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 25 Sep 2026 16:38:01 -0700 Subject: [PATCH 02/12] feat(clerk-js): resolve protect_check gates through the Protect modal SignIn and SignUp hand every fetched resource to ProtectCheckGate. When the resource carries a pending protect_check and no prebuilt component has registered as the handler, the gate opens the Protect modal through the UI bundle and waits until the modal reports the gate cleared, so the original call returns with the challenge already resolved. Requests made while a resolution is in flight, including the modal's own reload and submit, pass through untouched. Without a UI bundle, or in no-RHC builds, the gate leaves the resource as is. --- .../clerk-js/src/core/__tests__/clerk.test.ts | 73 +++++++++++++++ packages/clerk-js/src/core/clerk.ts | 41 +++++++++ .../src/core/protectCheckGate.test.ts | 89 +++++++++++++++++++ .../clerk-js/src/core/protectCheckGate.ts | 43 +++++++++ packages/clerk-js/src/core/resources/Base.ts | 9 +- .../core/resources/__tests__/SignIn.test.ts | 46 ++++++++++ .../core/resources/__tests__/SignUp.test.ts | 46 ++++++++++ 7 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 packages/clerk-js/src/core/protectCheckGate.test.ts create mode 100644 packages/clerk-js/src/core/protectCheckGate.ts diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 0607dc183c4..f3c7425a8ef 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -4005,6 +4005,79 @@ 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('opens the modal and resolves once the modal reports the gate cleared', async () => { + const openModal = vi.fn(); + const closeModal = vi.fn(); + const mockClerkUICtor = vi.fn(function () { + return { ensureMounted: () => Promise.resolve({ openModal, closeModal }) }; + }); + const sut = new Clerk(productionPublishableKey); + await sut.load({ ...mockedLoadOptions, ui: { ClerkUI: mockClerkUICtor } }); + const resource = gatedSignIn() as any; + + let settled = false; + const pending = sut.__internal_openProtectCheckModal({ resource }).then(() => { + settled = true; + }); + await vi.waitFor(() => expect(openModal).toHaveBeenCalled()); + expect(openModal).toHaveBeenCalledWith('protectCheck', { + resource, + onResolved: expect.any(Function), + }); + expect(settled).toBe(false); + + openModal.mock.calls[0][1].onResolved(); + await pending; + expect(closeModal).toHaveBeenCalledWith('protectCheck'); + expect(settled).toBe(true); + }); + + it('counts registered prebuilt handlers and releases each one once', () => { + const sut = new Clerk(productionPublishableKey); + expect(sut.__internal_hasProtectCheckHandler).toBe(false); + + const releaseFirst = sut.__internal_registerProtectCheckHandler(); + const releaseSecond = sut.__internal_registerProtectCheckHandler(); + expect(sut.__internal_hasProtectCheckHandler).toBe(true); + + releaseFirst(); + releaseFirst(); + expect(sut.__internal_hasProtectCheckHandler).toBe(true); + + releaseSecond(); + expect(sut.__internal_hasProtectCheckHandler).toBe(false); + }); + }); + 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..b5afeca80ef 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, @@ -991,6 +992,46 @@ export class Clerk implements ClerkInterface { .then(controls => controls.closeModal('enableOrganizationsPrompt')); }; + #protectCheckHandlers = 0; + + public __internal_registerProtectCheckHandler = (): (() => void) => { + this.#protectCheckHandlers += 1; + let released = false; + return () => { + if (released) { + return; + } + released = true; + this.#protectCheckHandlers -= 1; + }; + }; + + get __internal_hasProtectCheckHandler(): boolean { + return this.#protectCheckHandlers > 0; + } + + public __internal_openProtectCheckModal = ( + props: Omit<__internal_ProtectCheckModalProps, 'onResolved'>, + ): Promise => { + if (!this.#clerkUI) { + return Promise.resolve(); + } + return this.#clerkUI + .then(ui => ui.ensureMounted()) + .then( + controls => + new Promise(resolve => { + controls.openModal('protectCheck', { + ...props, + onResolved: () => { + controls.closeModal('protectCheck'); + resolve(); + }, + }); + }), + ); + }; + 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..2b4e0f52096 --- /dev/null +++ b/packages/clerk-js/src/core/protectCheckGate.test.ts @@ -0,0 +1,89 @@ +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 = () => ({ + protectCheck: { status: 'pending', token: 'tok', sdkUrl: 'https://protect.example.com/sdk.js' }, +}); +const clear = () => ({ protectCheck: null }); + +const mockClerk = (overrides: Partial = {}) => + ({ + __internal_hasProtectCheckHandler: false, + __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() as any; + + 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('does nothing for a resource that cannot carry a gate', async () => { + const clerk = mockClerk(); + await gate.resolve(clerk, { id: 'user_1' } as any); + expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled(); + }); + + it('does nothing when the resource has no gate', async () => { + const clerk = mockClerk(); + await gate.resolve(clerk, clear() as any); + expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled(); + }); + + it('leaves the gate to a registered prebuilt handler', async () => { + const clerk = mockClerk({ __internal_hasProtectCheckHandler: true }); + await gate.resolve(clerk, gated() as any); + expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled(); + }); + + it('returns immediately for requests made while a resolution is in flight', async () => { + const deferred = createDeferredPromise(); + const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) }); + + const outer = gate.resolve(clerk, gated() as any); + await gate.resolve(clerk, gated() as any); + expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1); + + deferred.resolve(); + await outer; + }); + + it('opens again for a later gate once the previous resolution finished', async () => { + const clerk = mockClerk(); + await gate.resolve(clerk, gated() as any); + await gate.resolve(clerk, gated() as any); + expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(2); + }); + + 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() as any)).rejects.toThrow('no ui'); + + const next = mockClerk(); + await gate.resolve(next, gated() as any); + 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..cee2f8ac783 --- /dev/null +++ b/packages/clerk-js/src/core/protectCheckGate.ts @@ -0,0 +1,43 @@ +import type { __internal_ProtectCheckModalProps } from '@clerk/shared/types'; + +import type { BaseResource, Clerk } from './resources/internal'; + +type ProtectGatedResource = __internal_ProtectCheckModalProps['resource']; + +const isProtectGated = (resource: BaseResource): resource is BaseResource & ProtectGatedResource => + 'protectCheck' in resource; + +/** + * 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. Skips when a prebuilt component has registered as + * the handler, when a resolution is already in flight (the modal's own reload and submit requests + * land here too), or in no-RHC builds where the challenge script must not be loaded. + */ +export class ProtectCheckGate { + private static instance: ProtectCheckGate; + + private inflight: Promise | null = null; + + public static getInstance(): ProtectCheckGate { + if (!ProtectCheckGate.instance) { + ProtectCheckGate.instance = new ProtectCheckGate(); + } + return ProtectCheckGate.instance; + } + + public async resolve(clerk: Clerk, resource: BaseResource): Promise { + if ( + __BUILD_DISABLE_RHC__ || + !isProtectGated(resource) || + !resource.protectCheck || + this.inflight || + clerk.__internal_hasProtectCheckHandler + ) { + return; + } + this.inflight = clerk.__internal_openProtectCheckModal({ resource }).finally(() => { + this.inflight = null; + }); + await this.inflight; + } +} diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 4ad63d5d01c..e549bb61a0d 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -13,6 +13,7 @@ import { debugLogger } from '@/utils/debug'; import { clerkMissingFapiClientInResources } from '../errors'; import type { FapiClient, FapiRequestInit, FapiResponse, FapiResponseJSON, HTTPMethod } from '../fapiClient'; import { FraudProtection } from '../fraudProtection'; +import { ProtectCheckGate } from '../protectCheckGate'; import { type Clerk, getClientResourceFromPayload } from './internal'; export type BaseFetchOptions = ClerkResourceReloadParams & { @@ -225,14 +226,18 @@ export abstract class BaseResource { opts, ); - return this.fromJSON((json?.response || json) as J); + const resource = this.fromJSON((json?.response || json) as J); + await ProtectCheckGate.getInstance().resolve(BaseResource.clerk, resource); + return resource; } protected async _baseMutate(params: BaseMutateParams): Promise { const { action, body, method, path, signal } = params; // TODO @userland-errors: const json = await BaseResource._fetch({ method, path: path || this.path(action), body, signal }); - return this.fromJSON((json?.response || json) as J); + const resource = this.fromJSON((json?.response || json) as J); + await ProtectCheckGate.getInstance().resolve(BaseResource.clerk, resource); + return resource; } protected async _baseMutateBypass(params: BaseMutateParams): Promise { 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..2677f11d524 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'; @@ -18,6 +19,10 @@ vi.mock('../../../utils/authenticateWithPopup', async () => { // Import the mocked function after mocking import { _futureAuthenticateWithPopup } from '../../../utils/authenticateWithPopup'; +beforeEach(() => { + vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined); +}); + // Mock the CaptchaChallenge module vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({ CaptchaChallenge: vi.fn().mockImplementation(function () { @@ -3674,3 +3679,44 @@ describe('SignIn', () => { }); }); }); + +describe('SignIn protect_check gate', () => { + const clerk = {} as any; + let previousClerk: any; + + beforeEach(() => { + previousClerk = BaseResource.clerk; + BaseResource.clerk = clerk; + }); + + afterEach(() => { + BaseResource.clerk = previousClerk; + }); + + const gatedResponse = { + client: null, + response: { + id: 'signin_123', + protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' }, + }, + }; + + it('hands the resource to the gate after a mutation', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); + const signIn = new SignIn({ id: 'signin_123' } as any); + + await signIn.create({ identifier: 'a@example.com' }); + + expect(signIn.protectCheck?.token).toBe('challenge-token'); + expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(clerk, signIn); + }); + + it('hands the resource to the gate after a reload', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); + const signIn = new SignIn({ id: 'signin_123' } as any); + + await signIn.reload(); + + expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(clerk, signIn); + }); +}); 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..76b2aa57046 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'; @@ -19,6 +20,10 @@ vi.mock('../../../utils/authenticateWithPopup', async () => { import { _futureAuthenticateWithPopup } from '../../../utils/authenticateWithPopup'; import { CaptchaChallenge } from '../../../utils/captcha/CaptchaChallenge'; +beforeEach(() => { + vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined); +}); + // Mock the CaptchaChallenge module vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({ CaptchaChallenge: vi.fn().mockImplementation(function () { @@ -2334,3 +2339,44 @@ describe('SignUp', () => { }); }); }); + +describe('SignUp protect_check gate', () => { + const clerk = {} as any; + let previousClerk: any; + + beforeEach(() => { + previousClerk = BaseResource.clerk; + BaseResource.clerk = clerk; + }); + + afterEach(() => { + BaseResource.clerk = previousClerk; + }); + + const gatedResponse = { + client: null, + response: { + id: 'signup_123', + protect_check: { status: 'pending', token: 'challenge-token', sdk_url: 'https://protect.example.com/sdk.js' }, + }, + }; + + it('hands the resource to the gate after a mutation', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); + const signUp = new SignUp({ id: 'signup_123' } as any); + + await signUp.update({ emailAddress: 'a@example.com' }); + + expect(signUp.protectCheck?.token).toBe('challenge-token'); + expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(clerk, signUp); + }); + + it('hands the resource to the gate after a reload', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); + const signUp = new SignUp({ id: 'signup_123' } as any); + + await signUp.reload(); + + expect(ProtectCheckGate.prototype.resolve).toHaveBeenCalledWith(clerk, signUp); + }); +}); From 9d6f178b95e144bccdb92fb800f0cf55860891ca Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 25 Sep 2026 16:38:03 -0700 Subject: [PATCH 03/12] feat(ui): add the ProtectCheckModal for custom flows A modal over the page with a blurred backdrop that runs the same challenge card as the prebuilt cards and reports back when the gate clears. The prebuilt SignIn and SignUp routes register themselves as the handler while mounted so clerk-js leaves their gates to the in-card flow. --- .changeset/protect-check-modal.md | 7 + packages/ui/src/Components.tsx | 29 +++- .../__tests__/ProtectCheckModal.test.tsx | 159 ++++++++++++++++++ .../components/ProtectCheckModal/index.tsx | 128 ++++++++++++++ .../SignIn/__tests__/SignInRoutes.test.tsx | 24 +++ packages/ui/src/components/SignIn/index.tsx | 2 + .../SignUp/__tests__/SignUpRoutes.test.tsx | 24 +++ packages/ui/src/components/SignUp/index.tsx | 2 + packages/ui/src/elements/contexts/index.tsx | 1 + packages/ui/src/lazyModules/components.ts | 6 + 10 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 .changeset/protect-check-modal.md create mode 100644 packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx create mode 100644 packages/ui/src/components/ProtectCheckModal/index.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx create mode 100644 packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx diff --git a/.changeset/protect-check-modal.md b/.changeset/protect-check-modal.md new file mode 100644 index 00000000000..7b1eb6f7051 --- /dev/null +++ b/.changeset/protect-check-modal.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/ui': minor +'@clerk/shared': patch +--- + +Resolve Clerk Protect challenges in custom sign-in and sign-up flows. When a request returns a `protect_check`, clerk-js opens Clerk's Protect UI in a modal over the page, runs the challenge, submits the proof, and then lets the original call return. Nothing needs to be rendered by the application. The prebuilt `` and `` components keep handling challenges inside their own cards. diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index ef830af901b..38f4434d9e3 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; @@ -180,6 +186,7 @@ interface ComponentsState { createOrganizationModal: null | CreateOrganizationModalProps; enableOrganizationsPromptModal: null | __internal_EnableOrganizationsPromptProps; blankCaptchaModal: null; + protectCheckModal: null | __internal_ProtectCheckModalProps; organizationSwitcherPrefetch: boolean; waitlistModal: null | WaitlistModalProps; checkoutDrawer: { @@ -313,6 +320,7 @@ const Components = (props: ComponentsProps) => { organizationSwitcherPrefetch: false, waitlistModal: null, blankCaptchaModal: null, + protectCheckModal: null, checkoutDrawer: { open: false, props: null, @@ -339,6 +347,7 @@ const Components = (props: ComponentsProps) => { createOrganizationModal, waitlistModal, blankCaptchaModal, + protectCheckModal, checkoutDrawer, planDetailsDrawer, subscriptionDetailsDrawer, @@ -686,6 +695,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('renders the challenge card and runs the challenge for the resource token', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + mockExecute.mockReturnValue(new Promise(() => {})); + + render( + , + { wrapper }, + ); + + screen.getByText('Verifying your request'); + await waitFor(() => expect(mockExecute).toHaveBeenCalledTimes(1)); + expect(mockExecute.mock.calls[0][0]).toMatchObject({ token: 'challenge-token' }); + }); + + it('calls onResolved once the gate clears', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + const onResolved = vi.fn(); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signIn.submitProtectCheck.mockImplementation(() => { + (fixtures.signIn as any).protectCheck = null; + return Promise.resolve(fixtures.signIn); + }); + + render( + , + { wrapper }, + ); + + await waitFor(() => expect(onResolved).toHaveBeenCalledTimes(1)); + expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' }); + }); + + 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('offers a retry and does not call onResolved when the challenge script fails', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + const onResolved = vi.fn(); + mockExecute.mockRejectedValue( + new ClerkRuntimeError('Protect check script failed to load', { + code: 'protect_check_script_load_failed', + }), + ); + + render( + , + { wrapper }, + ); + + await screen.findByRole('button', { name: /try again/i }); + expect(fixtures.signIn.submitProtectCheck).not.toHaveBeenCalled(); + 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..cc5b774101d --- /dev/null +++ b/packages/ui/src/components/ProtectCheckModal/index.tsx @@ -0,0 +1,128 @@ +import type { __internal_ProtectCheckModalProps, SignInResource, SignUpResource } from '@clerk/shared/types'; + +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; +import { actionBlockedDetailsFrom } from '@/ui/utils/actionBlocked'; + +import { ActionBlockedCard } from '../../common'; +import { + Box, + Button, + Col, + descriptors, + Flex, + Flow, + localizationKeys, + Spinner, + useLocalizations, +} from '../../customizables'; +import { useSpinDelay } from '../../hooks'; +import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner'; +import { Route, Switch } from '../../router'; + +const flowLocalizationKeys = { + signIn: { + title: localizationKeys('signIn.protectCheck.title'), + subtitle: localizationKeys('signIn.protectCheck.subtitle'), + loading: localizationKeys('signIn.protectCheck.loading'), + retryButton: localizationKeys('signIn.protectCheck.retryButton'), + }, + signUp: { + title: localizationKeys('signUp.protectCheck.title'), + subtitle: localizationKeys('signUp.protectCheck.subtitle'), + loading: localizationKeys('signUp.protectCheck.loading'), + retryButton: localizationKeys('signUp.protectCheck.retryButton'), + }, +}; + +const flowOf = (resource: SignInResource | SignUpResource) => + resource.pathRoot.endsWith('sign_ups') ? 'signUp' : 'signIn'; + +const ProtectCheckCard = withCardStateProvider(({ resource, onResolved }: __internal_ProtectCheckModalProps) => { + const card = useCardState(); + const { t } = useLocalizations(); + const keys = flowLocalizationKeys[flowOf(resource)]; + + const { containerRef, isRunning, isWidgetVisible, hasError, retry } = useProtectCheckRunner< + SignInResource | SignUpResource + >({ + getProtectCheck: () => resource.protectCheck, + getResource: () => resource, + reload: () => resource.reload(), + submitProtectCheck: params => resource.submitProtectCheck(params), + onResolved: (updated, isCancelled) => { + if (!isCancelled() && !updated.protectCheck) { + onResolved(); + } + return Promise.resolve(); + }, + }); + + const showSpinner = useSpinDelay(isRunning, { delay: 300 }); + + const blockedDetails = actionBlockedDetailsFrom(card.rawError); + if (blockedDetails) { + return ; + } + + return ( + + + + + + + + {card.error} + + + {showSpinner && !hasError && !isWidgetVisible ? ( + + + + ) : null} + {hasError ? ( +