Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Contributor

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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ui): spotlight interactive captcha during sign-in/sign-up by alexcarpenter · Pull Request #8907 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Contributor

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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): spotlight interactive captcha during sign-in/sign-up by alexcarpenter · Pull Request #8907 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Contributor

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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): spotlight interactive captcha during sign-in/sign-up by alexcarpenter · Pull Request #8907 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Contributor

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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ui): spotlight interactive captcha during sign-in/sign-up by alexcarpenter · Pull Request #8907 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Contributor

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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): spotlight interactive captcha during sign-in/sign-up by alexcarpenter · Pull Request #8907 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Contributor

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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): spotlight interactive captcha during sign-in/sign-up by alexcarpenter · Pull Request #8907 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Contributor

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
Original file line numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(ui): spotlight interactive captcha during sign-in/sign-up by alexcarpenter · Pull Request #8907 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
Contributor

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
Original file line numberDiff line numberDiff 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 DownExpand 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