Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/captcha-inline-spotlight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

When an interactive bot-protection challenge appears during sign-in or sign-up, the card now brings the challenge to the foreground — hiding the other fields and buttons until it's solved — so it's clear the "Verify you are human" check must be completed. Invisible challenges are unaffected.
5 changes: 5 additions & 0 deletions .changeset/captcha-spotlight-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix the inline captcha spotlight to expand correctly when an older clerk-js runtime is paired with a newer UI that expects the `data-cl-interactive` attribute — the challenge now falls back to the `maxHeight` heuristic so the spotlight still fires across version skews.
26 changes: 26 additions & 0 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,31 @@ function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType {
}
}

/**
* Sandbox-only demo aid: force an interactive Cloudflare Turnstile challenge so the inline captcha
* "spotlight" can be exercised without changing instance settings. Enable with `?captcha=interactive`.
*
* Overrides the loaded environment (never `src/`) to use Cloudflare's documented test sitekey
* `3x00000000000000000000FF` (which always escalates to interactive), render it inline, and disable
* the OAuth bypass so the "Continue with …" path also hits the challenge. Remove the query param to
* get the normal (mostly invisible) behavior back.
*/
function applyCaptchaSandboxOverrides() {
if (new URL(window.location.href).searchParams.get('captcha') !== 'interactive') {
return;
}
const env = (Clerk as unknown as { __internal_environment?: any })?.__internal_environment;
if (!env) {
console.warn('[sandbox] captcha=interactive: environment not loaded, skipping override');
return;
}
env.displayConfig.captchaPublicKey = '3x00000000000000000000FF'; // forces an interactive challenge
env.displayConfig.captchaWidgetType = 'smart'; // render inline into #clerk-captcha
env.displayConfig.captchaOauthBypass = []; // also exercise the OAuth ("Continue with …") path
env.userSettings.signUp.captcha_enabled = true; // ensure the widget actually runs
console.info('[sandbox] Forcing interactive captcha (test sitekey 3x…FF).');
}

function mountIndex(element: HTMLDivElement) {
assertClerkIsLoaded(Clerk);
const user = Clerk.user;
Expand Down Expand Up @@ -507,6 +532,7 @@ void (async () => {
},
localization: l[initialLocale as keyof typeof l],
});
applyCaptchaSandboxOverrides();
renderCurrentRoute();
const { pane } = await initControls();

Expand Down
64 changes: 64 additions & 0 deletions packages/clerk-js/src/utils/captcha/__tests__/turnstile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from '@clerk/shared/internal/clerk-js/constants';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { getTurnstileToken } from '../turnstile';

/**
* Regression guard for the inline "spotlight": the spotlight keys off
* `#clerk-captcha`'s `style.maxHeight !== '0'`. The common invisible flow (~99% of
* challenges) must never create or mutate `#clerk-captcha`, so its `maxHeight` stays
* `'0'` and no spotlight is ever triggered. This pins that existing behavior.
*/
describe('getTurnstileToken — invisible flow', () => {
let renderConfig: Record<string, any> | undefined;

beforeEach(() => {
renderConfig = undefined;
(window as any).turnstile = {
render: vi.fn((_selector: string, config: Record<string, any>) => {
renderConfig = config;
// Real Turnstile returns the widget id, then fires the callback asynchronously.
// Invisible challenges resolve without ever escalating to interactive, so the
// `before-interactive-callback` (the only code that expands #clerk-captcha) never fires.
setTimeout(() => config.callback('mock_token'), 0);
return 'mock_widget_id';
}),
remove: vi.fn(),
reset: vi.fn(),
};
});

afterEach(() => {
document.body.innerHTML = '';
delete (window as any).turnstile;
vi.restoreAllMocks();
});

it('renders into its own throwaway container, never #clerk-captcha', async () => {
// An inline #clerk-captcha may coexist on the page (e.g. a Start card is mounted),
// but an invisible challenge must never touch it.
const inline = document.createElement('div');
inline.id = CAPTCHA_ELEMENT_ID;
inline.style.maxHeight = '0';
document.body.appendChild(inline);

const result = await getTurnstileToken({
captchaProvider: 'turnstile',
siteKey: 'visible-key',
invisibleSiteKey: 'invisible-key',
widgetType: 'invisible',
});

expect(result).toEqual({ captchaToken: 'mock_token', captchaWidgetType: 'invisible' });

// The invisible flow targets its own `.clerk-invisible-captcha` div with the invisible key.
expect((window as any).turnstile.render).toHaveBeenCalledWith(`.${CAPTCHA_INVISIBLE_CLASSNAME}`, expect.anything());
expect(renderConfig?.sitekey).toBe('invisible-key');

// The spotlight signal on #clerk-captcha is untouched throughout → no spotlight.
expect(inline.style.maxHeight || '0').toBe('0');

// The temporary invisible container is created then cleaned up in `finally`.
expect(document.querySelector(`.${CAPTCHA_INVISIBLE_CLASSNAME}`)).toBeNull();
});
});
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
// and then expands to the correct height
visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px';
visibleWidget.style.marginBottom = '1.5rem';
visibleWidget.dataset.clInteractive = 'true';
}
}
},
Expand Down Expand Up @@ -242,6 +243,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
if (captchaTypeUsed === 'smart') {
const visibleWidget = document.getElementById(CAPTCHA_ELEMENT_ID);
if (visibleWidget) {
delete visibleWidget.dataset.clInteractive;
visibleWidget.style.maxHeight = '0';
visibleWidget.style.minHeight = 'unset';
visibleWidget.style.marginBottom = 'unset';
Expand Down
37 changes: 25 additions & 12 deletions packages/ui/src/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ function SignInStartInternal(): JSX.Element {
const hasSocialOrWeb3Buttons =
!!authenticatableSocialStrategies.length || !!web3FirstFactors.length || !!alternativePhoneCodeChannels.length;
const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = useState(false);
const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', {
...currentIdentifier,
isRequired: true,
Expand Down Expand Up @@ -597,6 +600,13 @@ function SignInStartInternal(): JSX.Element {
<Col
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{hasSocialOrWeb3Buttons && (
Expand Down Expand Up @@ -629,24 +639,27 @@ function SignInStartInternal(): JSX.Element {
/>
</Col>
<Col center>
<CaptchaElement />
<Form.SubmitButton hasArrow />
</Col>
</Form.Root>
) : null}
</SocialButtonsReversibleContainerWithDivider>
{!standardFormAttributes.length && <CaptchaElement />}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Col>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
{/* Kept outside descriptors.main so the spotlight's `inert` leaves this alternative action reachable. */}
{userSettings.attributes.passkey?.enabled &&
userSettings.passkeySettings.show_sign_in_button &&
isWebSupported && (
<Card.Action elementId={'usePasskey'}>
<Card.ActionLink
localizationKey={localizationKeys('signIn.start.actionLink__use_passkey')}
onClick={() => authenticateWithPasskey({ flow: 'discoverable' })}
/>
</Card.Action>
)}
</Card.Content>
<Card.Footer>
{userSettings.signUp.mode === SIGN_UP_MODES.PUBLIC && !isCombinedFlow && (
Expand Down
65 changes: 65 additions & 0 deletions packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants';
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
import type { SignInResource } from '@clerk/shared/types';
import { waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha';
import { bindCreateFixtures } from '@/test/create-fixtures';
import { fireEvent, mockWebAuthn, render, screen } from '@/test/utils';
import { CardStateProvider } from '@/ui/elements/contexts';
Expand Down Expand Up @@ -761,4 +763,67 @@ describe('SignInStart', () => {
);
});
});

describe('Captcha', () => {
it('renders the captcha widget in the form (email) config', async () => {
const { wrapper } = await createFixtures(f => {
f.withEmailAddress();
});
render(<SignInStart />, { wrapper });
expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull();
});

it('renders the captcha widget in the social-only (no form) config', async () => {
const { wrapper } = await createFixtures(f => {
f.withSocialProvider({ provider: 'google' });
});
render(<SignInStart />, { wrapper });
expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull();
});
});

describe('Captcha spotlight', () => {
const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement;

it('inerts the form/social subtree while interactive — never the captcha or header — and restores', async () => {
const { wrapper } = await createFixtures(f => {
f.withEmailAddress();
f.withSocialProvider({ provider: 'google' });
});
render(<SignInStart />, { wrapper });

const google = screen.getByText(/continue with google/i);
expect(google.closest('[inert]')).toBeNull();

simulateCaptchaInteractive(getCaptcha());

await waitFor(() => expect(google.closest('[inert]')).not.toBeNull());
// The captcha widget and the header stay outside the inert subtree.
expect(getCaptcha().closest('[inert]')).toBeNull();
expect(screen.getByRole('heading').closest('[inert]')).toBeNull();

simulateCaptchaResolved(getCaptcha());
await waitFor(() => expect(google.closest('[inert]')).toBeNull());
});

mockWebAuthn(() => {
it('keeps the passkey action available (outside the inert subtree) during the spotlight', async () => {
const { wrapper } = await createFixtures(f => {
f.withEmailAddress();
f.withPasskey();
f.withPasskeySettings({
allow_autofill: false,
show_sign_in_button: true,
});
});
render(<SignInStart />, { wrapper });

simulateCaptchaInteractive(getCaptcha());
// Spotlight is active (the form is inert)...
await waitFor(() => expect(screen.getByText(/email address/i).closest('[inert]')).not.toBeNull());
// ...but the passkey action remains reachable.
expect(screen.getByText('Use passkey instead').closest('[inert]')).toBeNull();
});
});
});
});
2 changes: 0 additions & 2 deletions packages/ui/src/components/SignUp/SignUpForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { LegalCheckbox } from '@/ui/elements/LegalConsentCheckbox';
import type { FormControlState } from '@/ui/utils/useFormControl';

import { Col, localizationKeys, useAppearance } from '../../customizables';
import { CaptchaElement } from '../../elements/CaptchaElement';
import { mqu } from '../../styledSystem';
import type { ActiveIdentifier, Fields } from './signUpFormHelpers';

Expand Down Expand Up @@ -116,7 +115,6 @@ export const SignUpForm = (props: SignUpFormProps) => {
</Col>
)}
<Col center>
<CaptchaElement />
<Col
gap={6}
sx={{
Expand Down
15 changes: 14 additions & 1 deletion packages/ui/src/components/SignUp/SignUpStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ function SignUpStartInternal(): JSX.Element {
);

const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false);
// When the captcha escalates to an interactive challenge, spotlight it by collapsing/inerting the
// rest of the card (see the descriptors.main column below).
const [captchaIsInteractive, setCaptchaIsInteractive] = React.useState(false);

const {
userSettings: { passwordSettings, usernameSettings },
Expand Down Expand Up @@ -442,6 +445,13 @@ function SignUpStartInternal(): JSX.Element {
direction='col'
elementDescriptor={descriptors.main}
gap={6}
// @ts-ignore - `inert` is not yet in the installed React types
inert={captchaIsInteractive ? '' : undefined}
// `display:none` (not `visibility:hidden`) so the collapsed column leaves flex flow and
// contributes no `gap` gutter to `Card.Content` — otherwise it injects empty space above
// the spotlighted captcha. Subtree stays mounted (form state preserved); `inert` is then
// redundant-but-harmless.
sx={captchaIsInteractive ? { display: 'none' } : undefined}
>
<SocialButtonsReversibleContainerWithDivider>
{(showOauthProviders || showWeb3Providers || showAlternativePhoneCodeProviders) && (
Expand All @@ -464,8 +474,11 @@ function SignUpStartInternal(): JSX.Element {
/>
)}
</SocialButtonsReversibleContainerWithDivider>
{!shouldShowForm && <CaptchaElement />}
</Flex>
<CaptchaElement
gapless
onInteractiveChange={setCaptchaIsInteractive}
/>
Comment on lines +478 to +481

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be breaking customers that have applied layout CSS changes on the Card.Content component, because it adds one more child to it.

The impact may be really small here, but let's make sure first.

Same applies for SignInStart.tsx

</Card.Content>

<Card.Footer>
Expand Down
55 changes: 54 additions & 1 deletion packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants';
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
import type { SignUpResource } from '@clerk/shared/types';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { simulateCaptchaInteractive, simulateCaptchaResolved } from '@/test/captcha';
import { bindCreateFixtures } from '@/test/create-fixtures';
import { fireEvent, render, screen, waitFor } from '@/test/utils';
import { CardStateProvider } from '@/ui/elements/contexts';
Expand Down Expand Up @@ -523,4 +525,55 @@ describe('SignUpStart', () => {
await waitFor(() => screen.getByText(/create your account/i));
});
});

describe('Captcha', () => {
// The ticket tests above mutate window.location and don't restore it (no afterEach in this
// suite); reset to a clean URL so the ticket flow doesn't fire during these renders.
beforeEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: new URL('http://localhost/sign-up'),
});
});

it('renders the captcha widget in the form (email) config', async () => {
const { wrapper } = await createFixtures(f => {
f.withEmailAddress({ required: true });
});
render(<SignUpStart />, { wrapper });
expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull();
});

it('renders the captcha widget in the social-only (no form) config', async () => {
const { wrapper } = await createFixtures(f => {
f.withSocialProvider({ provider: 'google' });
});
render(<SignUpStart />, { wrapper });
expect(document.getElementById(CAPTCHA_ELEMENT_ID)).not.toBeNull();
});

describe('spotlight', () => {
const getCaptcha = () => document.getElementById(CAPTCHA_ELEMENT_ID) as HTMLElement;

it('inerts the form/social subtree while interactive — never the captcha or header — and restores', async () => {
const { wrapper } = await createFixtures(f => {
f.withEmailAddress({ required: true });
f.withSocialProvider({ provider: 'google' });
});
render(<SignUpStart />, { wrapper });

const google = screen.getByText(/continue with google/i);
expect(google.closest('[inert]')).toBeNull();

simulateCaptchaInteractive(getCaptcha());

await waitFor(() => expect(google.closest('[inert]')).not.toBeNull());
expect(getCaptcha().closest('[inert]')).toBeNull();
expect(screen.getByRole('heading').closest('[inert]')).toBeNull();

simulateCaptchaResolved(getCaptcha());
await waitFor(() => expect(google.closest('[inert]')).toBeNull());
});
});
});
});
Loading
Loading