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/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..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, 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 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,