From ff0794e7c93c815d556bc5445355dea1b7e97859 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:02 -0700 Subject: [PATCH 1/6] feat(shared): type the Protect check modal and flow claims --- packages/shared/src/types/clerk.ts | 25 +++++++++++++++++++++++++ packages/shared/src/ui/types.ts | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 5c09303315b..e1a52c2096b 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -342,6 +342,23 @@ export interface Clerk { */ __internal_protectChallengeLoadTimeoutMs?: number; + /** + * Registers a prebuilt component that renders Protect challenges itself for the given flows, so + * clerk-js leaves a pending `protect_check` on those resources instead of opening its own modal. + * Returns the unregister function. + * + * @internal + */ + __internal_registerProtectCheckHandler?: (flows: ProtectCheckFlow[]) => () => void; + + /** + * 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. + * + * @internal + */ + __internal_resolvePendingProtectCheck?: () => Promise; + frontendApi: string; /** Your Clerk [Publishable Key](!publishable-key). */ @@ -1979,6 +1996,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: Error) => void; +}; + export type __internal_EnableOrganizationsPromptProps = { onSuccess?: () => void; onClose?: () => void; 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 From 807f18f74b5491b3b0f247d4fed9b2556b166f6c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:02 -0700 Subject: [PATCH 2/6] feat(clerk-js,react): resolve protect_check gates in custom flows through the Protect modal --- .changeset/protect-check-modal.md | 8 + packages/clerk-js/bundlewatch.config.json | 2 +- .../clerk-js/src/core/__tests__/clerk.test.ts | 156 ++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 55 +++++- .../src/core/protectCheckGate.test.ts | 144 ++++++++++++++++ .../clerk-js/src/core/protectCheckGate.ts | 64 +++++++ packages/clerk-js/src/core/resources/Base.ts | 8 +- .../clerk-js/src/core/resources/SignIn.ts | 6 + .../clerk-js/src/core/resources/SignUp.ts | 6 + .../core/resources/__tests__/SignIn.test.ts | 47 ++++++ .../core/resources/__tests__/SignUp.test.ts | 47 ++++++ .../src/components/HandleSSOCallback.tsx | 6 + .../__tests__/HandleSSOCallback.test.tsx | 44 +++++ packages/react/src/isomorphicClerk.ts | 4 + 14 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 .changeset/protect-check-modal.md create mode 100644 packages/clerk-js/src/core/protectCheckGate.test.ts create mode 100644 packages/clerk-js/src/core/protectCheckGate.ts diff --git a/.changeset/protect-check-modal.md b/.changeset/protect-check-modal.md new file mode 100644 index 00000000000..50a6a335d95 --- /dev/null +++ b/.changeset/protect-check-modal.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': minor +'@clerk/ui': minor +'@clerk/shared': patch +'@clerk/react': 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/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..ade9bcc5c02 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'; @@ -3186,6 +3187,49 @@ describe('Clerk singleton', () => { expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/protect-check'); }); }); + + it('owns both Protect flows while it routes a callback', async () => { + const release = vi.fn(); + const register = vi.spyOn(ProtectCheckGate.prototype, 'register').mockReturnValue(release); + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + onWindowLocationHost: () => false, + }), + ); + mockClientFetch.mockReturnValue( + Promise.resolve({ signedInSessions: [], signIn: new SignIn(null), signUp: new SignUp(null) }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + await sut.handleRedirectCallback(); + + expect(register).toHaveBeenCalledWith(['signIn', 'signUp']); + expect(release).toHaveBeenCalledTimes(1); + register.mockRestore(); + }); + + it('releases its Protect claim when routing a callback throws', async () => { + const release = vi.fn(); + const register = vi.spyOn(ProtectCheckGate.prototype, 'register').mockReturnValue(release); + mockEnvironmentFetch.mockReturnValue(Promise.resolve({ authConfig: {}, userSettings: mockUserSettings })); + mockClientFetch.mockReturnValue( + Promise.resolve({ signedInSessions: [], signIn: new SignIn(null), signUp: new SignUp(null) }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + await expect(sut.handleRedirectCallback()).rejects.toThrow(); + + expect(release).toHaveBeenCalledTimes(1); + register.mockRestore(); + }); }); describe('.handleEmailLinkVerification()', () => { @@ -4005,6 +4049,118 @@ 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('opens the modal and resolves once the modal reports the gate cleared', 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 resource = gatedSignIn() as any; + + let settled = false; + const pending = sut.__internal_openProtectCheckModal({ resource }).then(() => { + settled = true; + }); + await vi.waitFor(() => expect(openProtectCheckModal).toHaveBeenCalled()); + expect(openProtectCheckModal).toHaveBeenCalledWith({ + resource, + onResolved: expect.any(Function), + onFailed: expect.any(Function), + }); + expect(settled).toBe(false); + + openProtectCheckModal.mock.calls[0][0].onResolved(); + await pending; + expect(closeModal).toHaveBeenCalledWith('protectCheck'); + expect(settled).toBe(true); + }); + + 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'); + }); + + 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 = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + + await sut.__internal_resolvePendingProtectCheck(); + + expect(resolve).toHaveBeenCalledWith(sut, 'signIn', sut.client?.signIn); + expect(resolve).toHaveBeenCalledWith(sut, 'signUp', sut.client?.signUp); + resolve.mockRestore(); + }); + + it('registers prebuilt handlers with the gate', () => { + const release = vi.fn(); + const register = vi.spyOn(ProtectCheckGate.prototype, 'register').mockReturnValue(release); + const sut = new Clerk(productionPublishableKey); + + expect(sut.__internal_registerProtectCheckHandler(['signUp'])).toBe(release); + expect(register).toHaveBeenCalledWith(['signUp']); + register.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..422de57a0b8 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,47 @@ export class Clerk implements ClerkInterface { .then(controls => controls.closeModal('enableOrganizationsPrompt')); }; + public __internal_registerProtectCheckHandler = (flows: ProtectCheckFlow[]): (() => void) => { + return ProtectCheckGate.getInstance().register(flows); + }; + + public __internal_resolvePendingProtectCheck = async (): Promise => { + if (!this.client) { + return; + } + const gate = ProtectCheckGate.getInstance(); + await gate.resolve(this, 'signIn', this.client.signIn); + await gate.resolve(this, 'signUp', 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', {})); @@ -2569,7 +2613,16 @@ export class Clerk implements ClerkInterface { return this.__internal_handleResourceCallback(signInOrUp, params, customNavigate); }; - private _handleRedirectCallback = async ( + private _handleRedirectCallback = async (...args: Parameters): Promise => { + const release = ProtectCheckGate.getInstance().register(['signIn', 'signUp']); + try { + return await this._routeRedirectCallback(...args); + } finally { + release(); + } + }; + + private _routeRedirectCallback = async ( params: ResumeAfterProtectCheckParams, { signIn, 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..29530c3bcd0 --- /dev/null +++ b/packages/clerk-js/src/core/protectCheckGate.test.ts @@ -0,0 +1,144 @@ +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 clear = (id = 'sia_1') => ({ id, protectCheck: null }) 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, 'signIn', 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 when the resource has no gate', async () => { + const clerk = mockClerk(); + await gate.resolve(clerk, 'signUp', clear()); + expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled(); + }); + + it('leaves the gate to code that registered for its flow', async () => { + const clerk = mockClerk(); + const release = gate.register(['signIn']); + await gate.resolve(clerk, 'signIn', gated()); + release(); + expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled(); + }); + + it('opens the modal for a flow nobody registered for', async () => { + const clerk = mockClerk(); + const release = gate.register(['signUp']); + await gate.resolve(clerk, 'signIn', gated()); + release(); + expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1); + }); + + it('counts registrations per flow and releases each one once', async () => { + const clerk = mockClerk(); + const releaseCombined = gate.register(['signIn', 'signUp']); + const releaseSignUp = gate.register(['signUp']); + + releaseCombined(); + releaseCombined(); + await gate.resolve(clerk, 'signIn', gated('sia_1')); + await gate.resolve(clerk, 'signUp', gated('sua_1')); + expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1); + + releaseSignUp(); + await gate.resolve(clerk, 'signUp', gated('sua_1')); + expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(2); + }); + + it('leaves the proof submission to whatever runs the challenge', async () => { + const clerk = mockClerk(); + await gate.resolve(clerk, 'signIn', 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, 'signIn', gated()); + let innerSettled = false; + const inner = gate.resolve(clerk, 'signIn', 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('shares a rejection with callers on the same resource', async () => { + const deferred = createDeferredPromise(); + const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) }); + const blocked = new Error('blocked'); + + const outer = gate.resolve(clerk, 'signIn', gated()); + const inner = gate.resolve(clerk, 'signIn', gated()); + deferred.reject(blocked); + + await expect(outer).rejects.toBe(blocked); + await expect(inner).rejects.toBe(blocked); + }); + + 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, 'signIn', gated('sia_1')); + const other = gate.resolve(clerk, 'signUp', 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('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, 'signIn', gated())).rejects.toThrow('no ui'); + + const next = mockClerk(); + await gate.resolve(next, 'signIn', 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..831e45f547e --- /dev/null +++ b/packages/clerk-js/src/core/protectCheckGate.ts @@ -0,0 +1,64 @@ +import type { ProtectCheckFlow, 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, flows claimed through + * `register` by code that routes the gate itself, 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; + + private handlers: Record = { signIn: 0, signUp: 0 }; + + public static getInstance(): ProtectCheckGate { + if (!ProtectCheckGate.instance) { + ProtectCheckGate.instance = new ProtectCheckGate(); + } + return ProtectCheckGate.instance; + } + + /** Claims the given flows until the returned function is called, which counts only once. */ + public register(flows: ProtectCheckFlow[]): () => void { + flows.forEach(flow => (this.handlers[flow] += 1)); + let released = false; + return () => { + if (released) { + return; + } + released = true; + flows.forEach(flow => (this.handlers[flow] -= 1)); + }; + } + + public async resolve( + clerk: Clerk, + flow: ProtectCheckFlow, + resource: SignInResource | SignUpResource, + action?: string, + ): Promise { + if (__BUILD_DISABLE_RHC__ || action === 'protect_check') { + return; + } + while (this.inflight) { + if (this.inflight.resourceId === resource.id) { + return this.inflight.promise; + } + await this.inflight.promise.catch(() => {}); + } + if (!resource.protectCheck || this.handlers[flow] > 0) { + 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/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index 4ad63d5d01c..e1ff96b1061 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -232,7 +232,13 @@ export abstract class BaseResource { 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 this._afterMutate(params); + return resource; + } + + protected _afterMutate(_params: BaseMutateParams): Promise { + return Promise.resolve(); } protected async _baseMutateBypass(params: BaseMutateParams): Promise { diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index ebbaebeae07..e8ffc3c749f 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -100,6 +100,8 @@ import { clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; import { eventBus } from '../events'; +import { ProtectCheckGate } from '../protectCheckGate'; +import type { BaseMutateParams } from './internal'; import { BaseResource, UserData, Verification } from './internal'; /** @@ -681,6 +683,10 @@ export class SignIn extends BaseResource implements SignInResource { } }; + protected _afterMutate({ action }: BaseMutateParams): Promise { + return ProtectCheckGate.getInstance().resolve(SignIn.clerk, 'signIn', this, action); + } + protected fromJSON(data: SignInJSON | SignInJSONSnapshot | null): this { if (data) { this.id = data.id; diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index ac8c1d0b5c9..fecf5de5e97 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -69,6 +69,8 @@ import { clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; import { eventBus } from '../events'; +import { ProtectCheckGate } from '../protectCheckGate'; +import type { BaseMutateParams } from './internal'; import { BaseResource, SignUpVerifications } from './internal'; declare global { @@ -528,6 +530,10 @@ export class SignUp extends BaseResource implements SignUpResource { } }; + protected _afterMutate({ action }: BaseMutateParams): Promise { + return ProtectCheckGate.getInstance().resolve(SignUp.clerk, 'signUp', this, action); + } + protected fromJSON(data: SignUpJSON | SignUpJSONSnapshot | null): this { if (data) { this.id = data.id; 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..0050bef0448 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,45 @@ describe('SignIn', () => { }); }); }); + +describe('SignIn protect_check gate', () => { + const clerk = {} as any; + let previousClerk: any; + + beforeEach(() => { + previousClerk = SignIn.clerk; + SignIn.clerk = clerk; + }); + + afterEach(() => { + SignIn.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', signIn, undefined); + }); + + it('leaves reloads to the caller', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); + const signIn = new SignIn({ id: 'signin_123' } as any); + + await signIn.reload(); + + 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..8a267438ddb 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,45 @@ describe('SignUp', () => { }); }); }); + +describe('SignUp protect_check gate', () => { + const clerk = {} as any; + let previousClerk: any; + + beforeEach(() => { + previousClerk = SignUp.clerk; + SignUp.clerk = clerk; + }); + + afterEach(() => { + SignUp.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', signUp, undefined); + }); + + it('leaves reloads to the caller', async () => { + BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); + const signUp = new SignUp({ id: 'signup_123' } as any); + + await signUp.reload(); + + 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..476cf384eed 100644 --- a/packages/react/src/components/HandleSSOCallback.tsx +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -78,6 +78,12 @@ 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; + try { + await clerk.__internal_resolvePendingProtectCheck?.(); + } 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..4ed8ce726a3 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,47 @@ 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('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..a0c02640b80 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -1649,6 +1649,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __internal_resolvePendingProtectCheck = async (): Promise => { + await this.clerkjs?.__internal_resolvePendingProtectCheck?.(); + }; + __internal_resumeAfterProtectCheck = async ( params?: ResumeAfterProtectCheckParams, customNavigate?: (to: string) => Promise, From 46905682a0684dda762e19699e83d934ed055906 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:02 -0700 Subject: [PATCH 3/6] feat(ui): add the Protect modal and claim the flows the prebuilt components route themselves --- packages/ui/src/Components.tsx | 34 +++- .../components/GoogleOneTap/one-tap-start.tsx | 2 + .../__tests__/ProtectCheckModal.test.tsx | 173 ++++++++++++++++++ .../components/ProtectCheckModal/index.tsx | 63 +++++++ .../SignIn/__tests__/SignInRoutes.test.tsx | 25 +++ packages/ui/src/components/SignIn/index.tsx | 6 + .../SignUp/__tests__/SignUpRoutes.test.tsx | 25 +++ 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, 336 insertions(+), 1 deletion(-) 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/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} clerk.__internal_registerProtectCheckHandler?.(['signIn', 'signUp']), [clerk]); + async function oneTapCallback(response: GISCredentialResponse) { isPromptedRef.current = false; try { diff --git a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx new file mode 100644 index 00000000000..3d7b9e0ad3e --- /dev/null +++ b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx @@ -0,0 +1,173 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { OptionsProvider } from '../../../contexts'; +import { ProtectCheckModal } from '..'; + +vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({ + 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 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('keeps the modal open with a retry when the submit fails for a reason other than a block', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + const onResolved = vi.fn(); + const onFailed = vi.fn(); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signIn.submitProtectCheck.mockRejectedValue( + new ClerkAPIResponseError('invalid', { + status: 422, + data: [{ code: 'form_param_invalid', message: 'invalid' } as any], + }), + ); + + render( + , + { wrapper }, + ); + + await screen.findByRole('button', { name: /try again/i }); + expect(onFailed).not.toHaveBeenCalled(); + expect(onResolved).not.toHaveBeenCalled(); + }); + + it('hands a blocked verdict to onFailed instead of resolving', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.startSignInWithProtectCheck(); + }); + const blocked = new ClerkAPIResponseError('blocked', { + status: 403, + data: [{ code: 'action_blocked', message: 'blocked', meta: { traceId: 'trace_1' } } as any], + }); + mockExecute.mockResolvedValue('proof-abc'); + fixtures.signIn.submitProtectCheck.mockRejectedValue(blocked); + const onResolved = vi.fn(); + const onFailed = vi.fn(); + + render( + , + { wrapper }, + ); + + await waitFor(() => expect(onFailed).toHaveBeenCalledWith(blocked)); + 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..3990f9b3623 --- /dev/null +++ b/packages/ui/src/components/ProtectCheckModal/index.tsx @@ -0,0 +1,63 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; +import type { __internal_ProtectCheckModalProps, SignInResource, SignUpResource } from '@clerk/shared/types'; + +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).catch((error: unknown) => { + if (isClerkAPIResponseError(error) && error.errors[0]?.code === ERROR_CODES.FRAUD_ACTION_BLOCKED) { + onFailed(error); + } + throw error; + }), + onResolved: (updated, isCancelled) => { + if (!isCancelled() && !updated.protectCheck) { + onResolved(); + } + return Promise.resolve(); + }, + }); + + return ( + + ); + }, +); + +function ProtectCheckModal(props: __internal_ProtectCheckModalProps): JSX.Element { + return ( + +
+ + + + + + + +
+
+ ); +} + +ProtectCheckModal.displayName = 'ProtectCheckModal'; + +export { ProtectCheckModal }; diff --git a/packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx new file mode 100644 index 00000000000..fe12ac14ee7 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInRoutes.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { SignIn } from '..'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('SignIn', () => { + it('owns the protect check gate while mounted', async () => { + const { wrapper, fixtures } = await createFixtures(); + const unregister = vi.fn(); + const register = vi.fn(() => unregister); + fixtures.clerk.__internal_registerProtectCheckHandler = register; + + const { unmount } = render(, { wrapper }); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith(['signIn']); + expect(unregister).not.toHaveBeenCalled(); + unmount(); + expect(unregister).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/SignIn/index.tsx b/packages/ui/src/components/SignIn/index.tsx index 01c6a532497..83876f50638 100644 --- a/packages/ui/src/components/SignIn/index.tsx +++ b/packages/ui/src/components/SignIn/index.tsx @@ -52,6 +52,12 @@ function RedirectToSignIn() { function SignInRoutes(): JSX.Element { const signInContext = useSignInContext(); const signUpContext = useSignUpContext(); + const clerk = useClerk(); + const { isCombinedFlow } = signInContext; + React.useEffect( + () => clerk.__internal_registerProtectCheckHandler?.(isCombinedFlow ? ['signIn', 'signUp'] : ['signIn']), + [clerk, isCombinedFlow], + ); return ( diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx new file mode 100644 index 00000000000..6d8127c4204 --- /dev/null +++ b/packages/ui/src/components/SignUp/__tests__/SignUpRoutes.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { SignUp } from '..'; + +const { createFixtures } = bindCreateFixtures('SignUp'); + +describe('SignUp', () => { + it('owns the protect check gate while mounted', async () => { + const { wrapper, fixtures } = await createFixtures(); + const unregister = vi.fn(); + const register = vi.fn(() => unregister); + fixtures.clerk.__internal_registerProtectCheckHandler = register; + + const { unmount } = render(, { wrapper }); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith(['signUp']); + expect(unregister).not.toHaveBeenCalled(); + unmount(); + expect(unregister).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/SignUp/index.tsx b/packages/ui/src/components/SignUp/index.tsx index f5b94663dbe..e99aef64ffe 100644 --- a/packages/ui/src/components/SignUp/index.tsx +++ b/packages/ui/src/components/SignUp/index.tsx @@ -31,6 +31,8 @@ function SignUpRoutes(): JSX.Element { usePreloadTasks(); const signUpContext = useSignUpContext(); + const clerk = useClerk(); + React.useEffect(() => clerk.__internal_registerProtectCheckHandler?.(['signUp']), [clerk]); return ( 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/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, From c7143b34baba53d54d521e6bd9bd799d0a6f6fef Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:03 -0700 Subject: [PATCH 4/6] test(e2e): cover the Protect modal in custom flows and the prebuilt in-card route --- integration/tests/protect-check.test.ts | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) 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(); + }); +}); From 7547334701fe8ad97cd2e6b71d9f28b09e19879b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:03 -0700 Subject: [PATCH 5/6] fix(clerk-js,react,shared,ui): scope callback gates, narrow the One Tap claim, reject failed modals --- .../clerk-js/src/core/__tests__/clerk.test.ts | 31 ++++++- packages/clerk-js/src/core/clerk.ts | 13 ++- .../src/components/HandleSSOCallback.tsx | 4 +- .../__tests__/HandleSSOCallback.test.tsx | 22 +++++ packages/react/src/isomorphicClerk.ts | 5 +- packages/shared/src/types/clerk.ts | 7 +- .../__tests__/OneTapStart.test.tsx | 90 +++++++++++++++++++ .../components/GoogleOneTap/one-tap-start.tsx | 5 +- .../__tests__/ProtectCheckModal.test.tsx | 43 +++++++-- .../components/ProtectCheckModal/index.tsx | 11 +-- .../ui/src/hooks/useProtectCheckRunner.ts | 14 ++- 11 files changed, 211 insertions(+), 34 deletions(-) create mode 100644 packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index ade9bcc5c02..18ddda05689 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -4138,10 +4138,16 @@ describe('Clerk singleton', () => { expect(closeModal).toHaveBeenCalledWith('protectCheck'); }); - it('resolves gates the client carries on its sign-in and sign-up', async () => { - const resolve = vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined); + 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(); @@ -4150,6 +4156,27 @@ describe('Clerk singleton', () => { 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, 'signUp', sut.client?.signUp); + resolve.mockRestore(); + }); + it('registers prebuilt handlers with the gate', () => { const release = vi.fn(); const register = vi.spyOn(ProtectCheckGate.prototype, 'register').mockReturnValue(release); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 422de57a0b8..2d87b208dc0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -998,13 +998,18 @@ export class Clerk implements ClerkInterface { return ProtectCheckGate.getInstance().register(flows); }; - public __internal_resolvePendingProtectCheck = async (): Promise => { - if (!this.client) { + 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(); - await gate.resolve(this, 'signIn', this.client.signIn); - await gate.resolve(this, 'signUp', this.client.signUp); + if (flow !== 'signUp') { + await gate.resolve(this, 'signIn', client.signIn); + } + if (flow !== 'signIn') { + await gate.resolve(this, 'signUp', client.signUp); + } }; public __internal_openProtectCheckModal = ( diff --git a/packages/react/src/components/HandleSSOCallback.tsx b/packages/react/src/components/HandleSSOCallback.tsx index 476cf384eed..261c94fed8e 100644 --- a/packages/react/src/components/HandleSSOCallback.tsx +++ b/packages/react/src/components/HandleSSOCallback.tsx @@ -78,8 +78,10 @@ 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?.(); + await clerk.__internal_resolvePendingProtectCheck?.(flow); } catch { return navigateToSignIn(); } diff --git a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx index 4ed8ce726a3..1a29782611c 100644 --- a/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx +++ b/packages/react/src/components/__tests__/HandleSSOCallback.test.tsx @@ -142,6 +142,28 @@ describe('', () => { ); }); + 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')); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index a0c02640b80..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,8 +1650,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __internal_resolvePendingProtectCheck = async (): Promise => { - await this.clerkjs?.__internal_resolvePendingProtectCheck?.(); + __internal_resolvePendingProtectCheck = async (flow?: ProtectCheckFlow): Promise => { + await this.clerkjs?.__internal_resolvePendingProtectCheck?.(flow); }; __internal_resumeAfterProtectCheck = async ( diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index e1a52c2096b..8a944aa5925 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -354,10 +354,13 @@ export interface Clerk { /** * 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?: () => Promise; + __internal_resolvePendingProtectCheck?: (flow?: ProtectCheckFlow) => Promise; frontendApi: string; @@ -2001,7 +2004,7 @@ export type ProtectCheckFlow = 'signIn' | 'signUp'; export type __internal_ProtectCheckModalProps = { resource: SignInResource | SignUpResource; onResolved: () => void; - onFailed: (error: Error) => void; + onFailed: (error: unknown) => void; }; export type __internal_EnableOrganizationsPromptProps = { diff --git a/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx new file mode 100644 index 00000000000..943373a1c33 --- /dev/null +++ b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx @@ -0,0 +1,90 @@ +import type { SignInResource } from '@clerk/shared/types'; +import { waitFor } from '@testing-library/react'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { clearFetchCache } from '../../../hooks/useFetch'; +import { OneTapStart } from '../one-tap-start'; + +const { createFixtures } = bindCreateFixtures('GoogleOneTap'); + +const initialize = vi.fn(); + +const mountOneTap = async () => { + const { wrapper, fixtures } = await createFixtures(); + fixtures.environment.displayConfig.googleOneTapClientId = 'client-id'; + const release = vi.fn(); + const register = vi.fn().mockReturnValue(release); + fixtures.clerk.__internal_registerProtectCheckHandler = register; + + render(, { wrapper }); + await waitFor(() => expect(initialize).toHaveBeenCalledTimes(1)); + + const callback = initialize.mock.calls[0][0].callback as (response: { credential: string }) => Promise; + return { fixtures, register, release, callback }; +}; + +describe('OneTapStart', () => { + let consoleErrorSpy: ReturnType; + + beforeAll(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + beforeEach(() => { + initialize.mockReset(); + clearFetchCache(); + window.google = { accounts: { id: { initialize, prompt: vi.fn(), cancel: vi.fn() } } }; + }); + + it('does not claim the Protect flows on mount', async () => { + const { register } = await mountOneTap(); + + expect(register).not.toHaveBeenCalled(); + }); + + it('claims both Protect flows only while it authenticates and handles the callback', async () => { + const { fixtures, register, release, callback } = await mountOneTap(); + let finishAuth!: (value: SignInResource) => void; + fixtures.clerk.authenticateWithGoogleOneTap.mockReturnValue( + new Promise(resolve => { + finishAuth = resolve; + }), + ); + fixtures.clerk.handleGoogleOneTapCallback.mockResolvedValue(undefined); + + const pending = callback({ credential: 'cred' }); + + expect(register).toHaveBeenCalledWith(['signIn', 'signUp']); + expect(register.mock.invocationCallOrder[0]).toBeLessThan( + fixtures.clerk.authenticateWithGoogleOneTap.mock.invocationCallOrder[0], + ); + expect(release).not.toHaveBeenCalled(); + + finishAuth(fixtures.signIn); + await pending; + + expect(fixtures.clerk.handleGoogleOneTapCallback).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); + expect(release.mock.invocationCallOrder[0]).toBeGreaterThan( + fixtures.clerk.handleGoogleOneTapCallback.mock.invocationCallOrder[0], + ); + }); + + it('releases the claim when authentication throws', async () => { + const { fixtures, register, release, callback } = await mountOneTap(); + fixtures.clerk.authenticateWithGoogleOneTap.mockRejectedValue(new Error('nope')); + + await callback({ credential: 'cred' }); + + expect(register).toHaveBeenCalledWith(['signIn', 'signUp']); + expect(fixtures.clerk.handleGoogleOneTapCallback).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx b/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx index 1fc85763a96..62f1ff4cd0e 100644 --- a/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx +++ b/packages/ui/src/components/GoogleOneTap/one-tap-start.tsx @@ -19,10 +19,9 @@ function OneTapStartInternal(): JSX.Element | null { const ctx = useGoogleOneTapContext(); - useEffect(() => clerk.__internal_registerProtectCheckHandler?.(['signIn', 'signUp']), [clerk]); - async function oneTapCallback(response: GISCredentialResponse) { isPromptedRef.current = false; + const release = clerk.__internal_registerProtectCheckHandler?.(['signIn', 'signUp']); try { const res = await clerk.authenticateWithGoogleOneTap({ token: response.credential, @@ -30,6 +29,8 @@ function OneTapStartInternal(): JSX.Element | null { await clerk.handleGoogleOneTapCallback(res, ctx.generateCallbackUrls(window.location.href), navigate); } catch (e: any) { console.error(e); + } finally { + release?.(); } } diff --git a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx index 3d7b9e0ad3e..5373a2c68af 100644 --- a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx +++ b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx @@ -1,4 +1,5 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import { waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -83,20 +84,42 @@ describe('ProtectCheckModal', () => { expect(onResolved).not.toHaveBeenCalled(); }); - it('keeps the modal open with a retry when the submit fails for a reason other than a block', async () => { + it('hands a submit failure other than a block 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( - new ClerkAPIResponseError('invalid', { - status: 422, - data: [{ code: 'form_param_invalid', message: 'invalid' } as any], - }), + 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 screen.findByRole('button', { name: /try again/i }); - expect(onFailed).not.toHaveBeenCalled(); + 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(); }); diff --git a/packages/ui/src/components/ProtectCheckModal/index.tsx b/packages/ui/src/components/ProtectCheckModal/index.tsx index 3990f9b3623..09bd9788142 100644 --- a/packages/ui/src/components/ProtectCheckModal/index.tsx +++ b/packages/ui/src/components/ProtectCheckModal/index.tsx @@ -1,5 +1,3 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; -import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import type { __internal_ProtectCheckModalProps, SignInResource, SignUpResource } from '@clerk/shared/types'; import { withCardStateProvider } from '@/ui/elements/contexts'; @@ -18,19 +16,14 @@ const ProtectCheckModalCard = withCardStateProvider( getProtectCheck: () => resource.protectCheck, getResource: () => resource, reload: () => resource.reload(), - submitProtectCheck: params => - resource.submitProtectCheck(params).catch((error: unknown) => { - if (isClerkAPIResponseError(error) && error.errors[0]?.code === ERROR_CODES.FRAUD_ACTION_BLOCKED) { - onFailed(error); - } - throw error; - }), + submitProtectCheck: params => resource.submitProtectCheck(params), onResolved: (updated, isCancelled) => { if (!isCancelled() && !updated.protectCheck) { onResolved(); } return Promise.resolve(); }, + onError: onFailed, }); return ( 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; From 720e77e17f0bacfb67b946598f198eb8ca000ff3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 29 Sep 2026 12:09:03 -0700 Subject: [PATCH 6/6] test(clerk-js,ui): drop Protect unit tests the e2e tests now cover --- .../clerk-js/src/core/__tests__/clerk.test.ts | 38 -------------- .../src/core/protectCheckGate.test.ts | 42 --------------- .../core/resources/__tests__/SignIn.test.ts | 10 ---- .../core/resources/__tests__/SignUp.test.ts | 10 ---- .../__tests__/OneTapStart.test.tsx | 9 +--- .../__tests__/ProtectCheckModal.test.tsx | 52 +------------------ 6 files changed, 3 insertions(+), 158 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 18ddda05689..26b81d87873 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -4092,34 +4092,6 @@ describe('Clerk singleton', () => { expect(resource.protectCheck).not.toBeNull(); }); - it('opens the modal and resolves once the modal reports the gate cleared', 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 resource = gatedSignIn() as any; - - let settled = false; - const pending = sut.__internal_openProtectCheckModal({ resource }).then(() => { - settled = true; - }); - await vi.waitFor(() => expect(openProtectCheckModal).toHaveBeenCalled()); - expect(openProtectCheckModal).toHaveBeenCalledWith({ - resource, - onResolved: expect.any(Function), - onFailed: expect.any(Function), - }); - expect(settled).toBe(false); - - openProtectCheckModal.mock.calls[0][0].onResolved(); - await pending; - expect(closeModal).toHaveBeenCalledWith('protectCheck'); - expect(settled).toBe(true); - }); - it('closes the modal and rejects with the error the modal reports', async () => { const openProtectCheckModal = vi.fn(); const closeModal = vi.fn(); @@ -4176,16 +4148,6 @@ describe('Clerk singleton', () => { expect(resolve).toHaveBeenCalledWith(sut, 'signUp', sut.client?.signUp); resolve.mockRestore(); }); - - it('registers prebuilt handlers with the gate', () => { - const release = vi.fn(); - const register = vi.spyOn(ProtectCheckGate.prototype, 'register').mockReturnValue(release); - const sut = new Clerk(productionPublishableKey); - - expect(sut.__internal_registerProtectCheckHandler(['signUp'])).toBe(release); - expect(register).toHaveBeenCalledWith(['signUp']); - register.mockRestore(); - }); }); describe('ui.ClerkUI option', () => { diff --git a/packages/clerk-js/src/core/protectCheckGate.test.ts b/packages/clerk-js/src/core/protectCheckGate.test.ts index 29530c3bcd0..1c7ff65bcd8 100644 --- a/packages/clerk-js/src/core/protectCheckGate.test.ts +++ b/packages/clerk-js/src/core/protectCheckGate.test.ts @@ -9,7 +9,6 @@ const gated = (id = 'sia_1') => id, protectCheck: { status: 'pending', token: 'tok', sdkUrl: 'https://protect.example.com/sdk.js' }, }) as any; -const clear = (id = 'sia_1') => ({ id, protectCheck: null }) as any; const mockClerk = (overrides: Partial = {}) => ({ @@ -42,28 +41,6 @@ describe('ProtectCheckGate', () => { expect(settled).toBe(true); }); - it('does nothing when the resource has no gate', async () => { - const clerk = mockClerk(); - await gate.resolve(clerk, 'signUp', clear()); - expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled(); - }); - - it('leaves the gate to code that registered for its flow', async () => { - const clerk = mockClerk(); - const release = gate.register(['signIn']); - await gate.resolve(clerk, 'signIn', gated()); - release(); - expect(clerk.__internal_openProtectCheckModal).not.toHaveBeenCalled(); - }); - - it('opens the modal for a flow nobody registered for', async () => { - const clerk = mockClerk(); - const release = gate.register(['signUp']); - await gate.resolve(clerk, 'signIn', gated()); - release(); - expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1); - }); - it('counts registrations per flow and releases each one once', async () => { const clerk = mockClerk(); const releaseCombined = gate.register(['signIn', 'signUp']); @@ -80,12 +57,6 @@ describe('ProtectCheckGate', () => { expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(2); }); - it('leaves the proof submission to whatever runs the challenge', async () => { - const clerk = mockClerk(); - await gate.resolve(clerk, 'signIn', 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) }); @@ -103,19 +74,6 @@ describe('ProtectCheckGate', () => { expect(clerk.__internal_openProtectCheckModal).toHaveBeenCalledTimes(1); }); - it('shares a rejection with callers on the same resource', async () => { - const deferred = createDeferredPromise(); - const clerk = mockClerk({ __internal_openProtectCheckModal: vi.fn().mockReturnValue(deferred.promise) }); - const blocked = new Error('blocked'); - - const outer = gate.resolve(clerk, 'signIn', gated()); - const inner = gate.resolve(clerk, 'signIn', gated()); - deferred.reject(blocked); - - await expect(outer).rejects.toBe(blocked); - await expect(inner).rejects.toBe(blocked); - }); - 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); 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 0050bef0448..3af0c78e264 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -3701,16 +3701,6 @@ describe('SignIn protect_check gate', () => { }, }; - 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', signIn, undefined); - }); - it('leaves reloads to the caller', async () => { BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); const signIn = new SignIn({ id: 'signin_123' } as any); 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 8a267438ddb..6a87475de26 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -2361,16 +2361,6 @@ describe('SignUp protect_check gate', () => { }, }; - 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', signUp, undefined); - }); - it('leaves reloads to the caller', async () => { BaseResource._fetch = vi.fn().mockResolvedValue(gatedResponse); const signUp = new SignUp({ id: 'signup_123' } as any); diff --git a/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx index 943373a1c33..778f3d41e99 100644 --- a/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx +++ b/packages/ui/src/components/GoogleOneTap/__tests__/OneTapStart.test.tsx @@ -43,13 +43,7 @@ describe('OneTapStart', () => { window.google = { accounts: { id: { initialize, prompt: vi.fn(), cancel: vi.fn() } } }; }); - it('does not claim the Protect flows on mount', async () => { - const { register } = await mountOneTap(); - - expect(register).not.toHaveBeenCalled(); - }); - - it('claims both Protect flows only while it authenticates and handles the callback', async () => { + it('claims both Protect flows only while it authenticates, not on mount', async () => { const { fixtures, register, release, callback } = await mountOneTap(); let finishAuth!: (value: SignInResource) => void; fixtures.clerk.authenticateWithGoogleOneTap.mockReturnValue( @@ -58,6 +52,7 @@ describe('OneTapStart', () => { }), ); fixtures.clerk.handleGoogleOneTapCallback.mockResolvedValue(undefined); + expect(register).not.toHaveBeenCalled(); const pending = callback({ credential: 'cred' }); diff --git a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx index 5373a2c68af..66f9d2031a0 100644 --- a/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx +++ b/packages/ui/src/components/ProtectCheckModal/__tests__/ProtectCheckModal.test.tsx @@ -31,30 +31,6 @@ beforeEach(() => { }); describe('ProtectCheckModal', () => { - 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(); @@ -84,7 +60,7 @@ describe('ProtectCheckModal', () => { expect(onResolved).not.toHaveBeenCalled(); }); - it('hands a submit failure other than a block to onFailed instead of offering a retry', async () => { + it('hands a submit failure to onFailed instead of offering a retry', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.startSignInWithProtectCheck(); }); @@ -138,32 +114,6 @@ describe('ProtectCheckModal', () => { expect(onResolved).not.toHaveBeenCalled(); }); - it('hands a blocked verdict to onFailed instead of resolving', async () => { - const { wrapper, fixtures } = await createFixtures(f => { - f.startSignInWithProtectCheck(); - }); - const blocked = new ClerkAPIResponseError('blocked', { - status: 403, - data: [{ code: 'action_blocked', message: 'blocked', meta: { traceId: 'trace_1' } } as any], - }); - mockExecute.mockResolvedValue('proof-abc'); - fixtures.signIn.submitProtectCheck.mockRejectedValue(blocked); - const onResolved = vi.fn(); - const onFailed = vi.fn(); - - render( - , - { wrapper }, - ); - - await waitFor(() => expect(onFailed).toHaveBeenCalledWith(blocked)); - 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();