diff --git a/.changeset/mosaic-captcha.md b/.changeset/mosaic-captcha.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-captcha.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx new file mode 100644 index 00000000000..cae73cad5ee --- /dev/null +++ b/packages/mosaic/src/components/captcha/__tests__/captcha.browser.test.tsx @@ -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( +
+ +
, + ); + + expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'dark'); + }); + + it('lets an explicit theme win over the color scheme', () => { + const { container } = render( +
+ +
, + ); + + expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'light'); + }); + + it('uses the light theme when no color scheme is set', () => { + const { container } = render(); + + 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( +
+ +
, + ); + + expect(container.querySelector('#clerk-captcha')).toHaveAttribute('data-cl-theme', 'auto'); + }); +}); diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx new file mode 100644 index 00000000000..9e298b89703 --- /dev/null +++ b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx @@ -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( + , + ); + + 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(); + const element = container.querySelector('#clerk-captcha'); + element?.setAttribute('data-cl-interactive', 'true'); + + rerender(); + + expect(element).toHaveAttribute('data-cl-interactive', 'true'); + expect(element).toHaveAttribute('data-cl-theme', 'dark'); + }); +}); diff --git a/packages/mosaic/src/components/captcha/captcha.styles.ts b/packages/mosaic/src/components/captcha/captcha.styles.ts new file mode 100644 index 00000000000..aee67d75d32 --- /dev/null +++ b/packages/mosaic/src/components/captcha/captcha.styles.ts @@ -0,0 +1,10 @@ +import * as stylex from '@stylexjs/stylex'; + +export const styles = stylex.create({ + root: { + position: { + default: 'absolute', + ':where([data-cl-interactive])': 'static', + }, + }, +}); diff --git a/packages/mosaic/src/components/captcha/captcha.tsx b/packages/mosaic/src/components/captcha/captcha.tsx new file mode 100644 index 00000000000..36c389fcd44 --- /dev/null +++ b/packages/mosaic/src/components/captcha/captcha.tsx @@ -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, '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 + * + */ +export const Captcha = React.forwardRef(function MosaicCaptcha( + { render, xstyle, theme, size, language, ...rest }, + ref, +) { + const [element, setElement] = React.useState(null); + const [schemeTheme, setSchemeTheme] = React.useState(); + + 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'; +} diff --git a/packages/mosaic/src/components/captcha/index.ts b/packages/mosaic/src/components/captcha/index.ts new file mode 100644 index 00000000000..7e889064602 --- /dev/null +++ b/packages/mosaic/src/components/captcha/index.ts @@ -0,0 +1,2 @@ +export { Captcha } from './captcha'; +export type { CaptchaProps } from './captcha'; diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index a0c8d639597..398f9b9c67a 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -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'; diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 76da1ecc6e7..e9b30c87af9 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -124,6 +124,7 @@ const docModules: Record> = { 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: { diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 0453bfd0a27..771a31ca3e9 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -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, @@ -996,6 +997,11 @@ const fieldModule: StoryModule = { VisuallyHiddenLabel: FieldVisuallyHiddenLabel, }; +const captchaModule: StoryModule = { + meta: captchaMeta, + Default: CaptchaDefault, +}; + const visuallyHiddenModule: StoryModule = { meta: visuallyHiddenMeta, Default: VisuallyHiddenDefault, @@ -1442,6 +1448,7 @@ export const registry: StoryModule[] = [ toastModule, tooltipComponentModule, fieldModule, + captchaModule, visuallyHiddenModule, // Primitives accordionModule, diff --git a/packages/swingset/src/lib/test-turnstile.ts b/packages/swingset/src/lib/test-turnstile.ts new file mode 100644 index 00000000000..fdace8593b7 --- /dev/null +++ b/packages/swingset/src/lib/test-turnstile.ts @@ -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; + remove: (id: string) => void; +} + +function loadTurnstile(): Promise { + 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; +} diff --git a/packages/swingset/src/stories/captcha.mdx b/packages/swingset/src/stories/captcha.mdx new file mode 100644 index 00000000000..c82bf87e739 --- /dev/null +++ b/packages/swingset/src/stories/captcha.mdx @@ -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. + + + +## Props + + ReactNode' }]} +/> + +## Usage + + + +## Styling + +| Part | Stable slot class | Description | +| --------- | ----------------- | ------------------------------------------------------------------------------------------------------------ | +| `Captcha` | `.cl-captcha` | `div#clerk-captcha`. `data-cl-interactive="true"` is present while Turnstile shows an interactive challenge. | diff --git a/packages/swingset/src/stories/captcha.stories.tsx b/packages/swingset/src/stories/captcha.stories.tsx new file mode 100644 index 00000000000..3c088dabe0f --- /dev/null +++ b/packages/swingset/src/stories/captcha.stories.tsx @@ -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) { + return props as unknown as CaptchaProps; +} + +function TurnstileCaptcha({ size }: Pick) { + const ref = useRef(null); + const status = useTestTurnstile(() => ref.current); + + return ( +
+ + {status} +
+ ); +} + +export function Default(props: Record) { + const { size } = knobsAsProps(props); + const { resolvedTheme } = useTheme(); + return ( + + ); +}