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
2 changes: 2 additions & 0 deletions .changeset/mosaic-captcha.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { render } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';

import { Captcha } from '../captcha';

describe('Captcha color scheme', () => {
it('follows the color scheme it renders in when no theme is set', () => {
const { container } = render(
<div style={{ colorScheme: 'dark' }}>
<Captcha />
</div>,
);

expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'dark');
});

it('lets an explicit theme win over the color scheme', () => {
const { container } = render(
<div style={{ colorScheme: 'dark' }}>
<Captcha theme='light' />
</div>,
);

expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'light');
});

it('uses the light theme when no color scheme is set', () => {
const { container } = render(<Captcha />);

expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'light');
});

it('leaves the theme to Turnstile when the color scheme follows the visitor', () => {
const { container } = render(
<div style={{ colorScheme: 'light dark' }}>
<Captcha />
</div>,
);

expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'auto');
});
});
34 changes: 34 additions & 0 deletions packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { render } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';

import { Captcha } from '../captcha';

describe('Captcha', () => {
it('renders the element clerk-js mounts Turnstile into, with the widget options', () => {
const { container } = render(
<Captcha
theme='dark'
size='compact'
language='fr'
/>,
);

const element = container.querySelector('#clerk-captcha');
expect(element).toHaveAttribute('data-cl-theme', 'dark');
expect(element).toHaveAttribute('data-cl-size', 'compact');
expect(element).toHaveAttribute('data-cl-language', 'fr');
expect(element).toBeEmptyDOMElement();
});

it('keeps the interactive state clerk-js sets across re-renders', () => {
const { container, rerender } = render(<Captcha theme='light' />);
const element = container.querySelector<HTMLElement>('#clerk-captcha');
element?.setAttribute('data-cl-interactive', 'true');

rerender(<Captcha theme='dark' />);

expect(element).toHaveAttribute('data-cl-interactive', 'true');
expect(element).toHaveAttribute('data-cl-theme', 'dark');
});
});
10 changes: 10 additions & 0 deletions packages/mosaic/src/components/captcha/captcha.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as stylex from '@stylexjs/stylex';

export const styles = stylex.create({
root: {
position: {
default: 'absolute',
':where([data-cl-interactive])': 'static',
},
},
});
66 changes: 66 additions & 0 deletions packages/mosaic/src/components/captcha/captcha.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants';
import { useSafeLayoutEffect } from '@clerk/shared/react';
import * as stylex from '@stylexjs/stylex';
import React from 'react';

import { useRender } from '../../primitives/utils';
import { getComputedStyle } from '../../primitives/utils/dom';
import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../styles/reset.styles';
import { styles } from './captcha.styles';

export interface CaptchaProps extends Omit<MosaicComponentProps<'div'>, 'id' | 'children'> {
/** The Turnstile widget's theme. When unset, it matches the `color-scheme` the element renders in: `dark`, `light` (also when none is set), or the visitor's color scheme when both are allowed. */
theme?: 'auto' | 'light' | 'dark';
/** The Turnstile widget's size. Turnstile uses `normal` when it is unset. */
size?: 'normal' | 'flexible' | 'compact';
/** The Turnstile widget's language, an ISO 639-1 code such as `en` or `en-us`. Turnstile follows the visitor's language when it is unset. */
language?: string;
}

/**
* The element clerk-js renders Cloudflare Turnstile into when a sign-in or sign-up requires a
* CAPTCHA. It stays empty and collapsed unless Turnstile asks the visitor to solve an interactive
* challenge, when clerk-js expands it and sets `data-cl-interactive`. Until then it is out of the
* layout, so it adds no gap to a flex parent. Render one per page, mounted before the sign-in or
* sign-up is created.
*
* @example
* <Captcha theme='dark' />
*/
export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>(function MosaicCaptcha(
{ render, xstyle, theme, size, language, ...rest },
ref,
) {
const [element, setElement] = React.useState<HTMLDivElement | null>(null);
const [schemeTheme, setSchemeTheme] = React.useState<CaptchaProps['theme']>();

useSafeLayoutEffect(() => {
if (theme || !element) {
return;
}
setSchemeTheme(themeFromColorScheme(getComputedStyle(element).colorScheme));
});

return useRender({
defaultTagName: 'div',
render,
ref: [ref, setElement],
props: {
...mergeStyleProps(themeProps('captcha'), stylex.props(reset.base, styles.root, xstyle), rest),
id: CAPTCHA_ELEMENT_ID,
'data-cl-theme': theme ?? schemeTheme,
'data-cl-size': size,
'data-cl-language': language,
},
});
});

function themeFromColorScheme(colorScheme: string): CaptchaProps['theme'] {
const schemes = colorScheme.split(/\s+/);
if (!schemes.includes('dark')) {
return 'light';
}
return schemes.includes('light') ? 'auto' : 'dark';
}
2 changes: 2 additions & 0 deletions packages/mosaic/src/components/captcha/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Captcha } from './captcha';
export type { CaptchaProps } from './captcha';
2 changes: 2 additions & 0 deletions packages/mosaic/src/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type { BrandingProps } from '../components/branding';
export type { BannerDescriptionProps, BannerLabelProps, BannerRootProps } from '../components/banner';
export { Button, SubmitButton } from '../components/button';
export type { ButtonProps, SpinDelayOptions, SubmitButtonProps } from '../components/button';
export { Captcha } from '../components/captcha';
export type { CaptchaProps } from '../components/captcha';
export { Card } from '../components/card';
export { Checkbox } from '../components/checkbox';
export type { CheckboxProps } from '../components/checkbox';
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
tooltip: dynamic(() => import('../stories/tooltip.component.mdx')),
field: dynamic(() => import('../stories/field.component.mdx')),
flow: dynamic(() => import('../stories/flow.component.mdx')),
captcha: dynamic(() => import('../stories/captcha.mdx')),
'visually-hidden': dynamic(() => import('../stories/visually-hidden.mdx')),
},
primitives: {
Expand Down
7 changes: 7 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
meta as bannerMeta,
} from '../stories/banner.stories';
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
import { Default as CaptchaDefault, meta as captchaMeta } from '../stories/captcha.stories';
import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories';
import {
Default as CheckboxDefault,
Expand Down Expand Up @@ -996,6 +997,11 @@ const fieldModule: StoryModule = {
VisuallyHiddenLabel: FieldVisuallyHiddenLabel,
};

const captchaModule: StoryModule = {
meta: captchaMeta,
Default: CaptchaDefault,
};

const visuallyHiddenModule: StoryModule = {
meta: visuallyHiddenMeta,
Default: VisuallyHiddenDefault,
Expand Down Expand Up @@ -1442,6 +1448,7 @@ export const registry: StoryModule[] = [
toastModule,
tooltipComponentModule,
fieldModule,
captchaModule,
visuallyHiddenModule,
// Primitives
accordionModule,
Expand Down
74 changes: 74 additions & 0 deletions packages/swingset/src/lib/test-turnstile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react';

const TURNSTILE_SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
const FORCE_INTERACTIVE_SITEKEY = '3x00000000000000000000FF';

interface Turnstile {
render: (container: HTMLElement, options: Record<string, unknown>) => string;
remove: (id: string) => void;
}

function loadTurnstile(): Promise<Turnstile> {
const existing = (window as { turnstile?: Turnstile }).turnstile;
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = TURNSTILE_SCRIPT;
script.async = true;
script.onload = () => resolve((window as { turnstile?: Turnstile }).turnstile as Turnstile);
script.onerror = reject;
document.head.appendChild(script);
});
}

export function useTestTurnstile(getElement: () => HTMLElement | null | undefined): string {
const [status, setStatus] = useState('Loading Turnstile');

useEffect(() => {
let id: string | undefined;
let turnstile: Turnstile | undefined;
let cancelled = false;
const element = getElement();
if (!element) {
return undefined;
}
element.style.maxHeight = '0';
void loadTurnstile()
.then(loaded => {
if (cancelled) {
return;
}
turnstile = loaded;
id = loaded.render(element, {
sitekey: FORCE_INTERACTIVE_SITEKEY,
appearance: 'interaction-only',
theme: element.dataset.clTheme ?? 'auto',
size: element.dataset.clSize ?? 'normal',
retry: 'never',
'before-interactive-callback': () => {
element.style.maxHeight = 'unset';
element.style.minHeight = element.dataset.clSize === 'compact' ? '140px' : '68px';
element.style.marginBottom = '1.5rem';
element.dataset.clInteractive = 'true';
setStatus('Interactive challenge shown');
},
callback: () => {
delete element.dataset.clInteractive;
setStatus('Solved');
},
});
setStatus('Waiting for Turnstile');
})
.catch(() => setStatus('Turnstile could not load'));
return () => {
cancelled = true;
if (turnstile && id) {
turnstile.remove(id);
}
};
}, []);

return status;
}
38 changes: 38 additions & 0 deletions packages/swingset/src/stories/captcha.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as CaptchaStories from './captcha.stories';

# Captcha

The element clerk-js renders Cloudflare Turnstile into when a sign-in or sign-up requires a CAPTCHA. It renders an empty `div` with the id `clerk-captcha`. clerk-js keeps it collapsed, and expands it and sets `data-cl-interactive` only when Turnstile asks the visitor to solve an interactive challenge.

Render one per page, inside the step that creates the sign-in or sign-up, so it is mounted when clerk-js looks for it. Without it, clerk-js falls back to an invisible widget and has nowhere to show an interactive challenge.

## Playground

The playground stands in for clerk-js: it loads Turnstile with Cloudflare's test sitekey that always asks for an interactive challenge, and renders it into the element the way clerk-js does. It needs network access to `challenges.cloudflare.com`. In an app, the element stays empty unless Turnstile asks for a challenge. Without a `theme`, the widget matches the `color-scheme` it renders in, so it follows Swingset's dark mode toggle.

<Preview
name='Default'
storyModule={CaptchaStories}
/>

## Props

<PropTable
meta={CaptchaStories.meta}
extra={[
{ name: 'theme', type: "'auto' | 'light' | 'dark'", default: 'the color scheme it renders in' },
{ name: 'language', type: 'string' }, { name: 'render', type: '(props) => ReactNode' }]}
/>

## Usage

<Usage
component='Captcha'
module='@clerk/mosaic/components/captcha'
/>

## Styling

| Part | Stable slot class | Description |
| --------- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
| `Captcha` | `.cl-captcha` | `div#clerk-captcha`. `data-cl-interactive="true"` is present while Turnstile shows an interactive challenge. |
52 changes: 52 additions & 0 deletions packages/swingset/src/stories/captcha.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { CaptchaProps } from '@clerk/mosaic/components/captcha';
import { Captcha } from '@clerk/mosaic/components/captcha';
import { useTheme } from 'next-themes';
import { useRef } from 'react';

import { useTestTurnstile } from '@/lib/test-turnstile';
import type { StoryMeta } from '@/lib/types';

export { default as __source } from './captcha.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
status: 'stable',
title: 'Captcha',
source: 'packages/mosaic/src/components/captcha/captcha.tsx',
styles: {
_variants: {
size: { normal: {}, flexible: {}, compact: {} },
},
_defaultVariants: { size: 'normal' },
},
};

function knobsAsProps(props: Record<string, unknown>) {
return props as unknown as CaptchaProps;
}

function TurnstileCaptcha({ size }: Pick<CaptchaProps, 'size'>) {
const ref = useRef<HTMLDivElement>(null);
const status = useTestTurnstile(() => ref.current);

return (
<div style={{ display: 'grid', gap: 8, justifyItems: 'start' }}>
<Captcha
ref={ref}
size={size}
/>
<span style={{ fontSize: 13, opacity: 0.7 }}>{status}</span>
</div>
);
}

export function Default(props: Record<string, unknown>) {
const { size } = knobsAsProps(props);
const { resolvedTheme } = useTheme();
return (
<TurnstileCaptcha
key={`${resolvedTheme}-${size}`}
size={size}
/>
);
}
Loading