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