From 28179323d9891bd13625e32c5682a3276e73cdae Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Wed, 26 Feb 2025 15:12:38 +0200 Subject: [PATCH] feat(clerk-js,types): Introduce `captcha` appearance property for the CAPTCHA widget (#5184) --- .changeset/fluffy-hairs-thank.md | 6 ++ packages/clerk-js/bundlewatch.config.json | 2 +- .../ui/components/BlankCaptchaModal/index.tsx | 15 +++- .../__tests__/parseAppearance.test.tsx | 84 +++++++++++++++++++ .../src/ui/customizables/parseAppearance.ts | 25 +++++- .../src/ui/elements/CaptchaElement.tsx | 10 ++- .../clerk-js/src/utils/captcha/turnstile.ts | 54 +++++++++++- packages/types/src/appearance.ts | 24 ++++++ 8 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 .changeset/fluffy-hairs-thank.md diff --git a/.changeset/fluffy-hairs-thank.md b/.changeset/fluffy-hairs-thank.md new file mode 100644 index 00000000000..24f4eb8be20 --- /dev/null +++ b/.changeset/fluffy-hairs-thank.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Introduce the `appearance.captcha` property for the CAPTCHA widget diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index fe0ea8fb266..fd73a9957e9 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "./dist/clerk.js", "maxSize": "560kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "75kB" }, - { "path": "./dist/clerk.headless.js", "maxSize": "48.2KB" }, + { "path": "./dist/clerk.headless.js", "maxSize": "48.3KB" }, { "path": "./dist/ui-common*.js", "maxSize": "89KB" }, { "path": "./dist/vendors*.js", "maxSize": "25KB" }, { "path": "./dist/coinbase*.js", "maxSize": "35.5KB" }, diff --git a/packages/clerk-js/src/ui/components/BlankCaptchaModal/index.tsx b/packages/clerk-js/src/ui/components/BlankCaptchaModal/index.tsx index 2e6c240f34c..a1f35ba4b40 100644 --- a/packages/clerk-js/src/ui/components/BlankCaptchaModal/index.tsx +++ b/packages/clerk-js/src/ui/components/BlankCaptchaModal/index.tsx @@ -1,12 +1,23 @@ -import { Flow } from '../../customizables'; +import { Flow, useAppearance, useLocalizations } from '../../customizables'; import { Card, withCardStateProvider } from '../../elements'; import { Route, Switch } from '../../router'; const BlankCard = withCardStateProvider(() => { + const { parsedCaptcha } = useAppearance(); + const { locale } = useLocalizations(); + const captchaTheme = parsedCaptcha?.theme; + const captchaSize = parsedCaptcha?.size; + const captchaLanguage = parsedCaptcha?.language || locale; + return ( -
+
); diff --git a/packages/clerk-js/src/ui/customizables/__tests__/parseAppearance.test.tsx b/packages/clerk-js/src/ui/customizables/__tests__/parseAppearance.test.tsx index 9976b0fd17b..bc3982c82b0 100644 --- a/packages/clerk-js/src/ui/customizables/__tests__/parseAppearance.test.tsx +++ b/packages/clerk-js/src/ui/customizables/__tests__/parseAppearance.test.tsx @@ -380,3 +380,87 @@ describe('AppearanceProvider layout flows', () => { expect(result.current.parsedElements[0]['alert'].backgroundColor).toBe(themeBColor); }); }); + +describe('AppearanceProvider captcha', () => { + it('sets the parsedCaptcha correctly from the globalAppearance prop', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAppearance(), { wrapper }); + expect(result.current.parsedCaptcha.theme).toBe('dark'); + expect(result.current.parsedCaptcha.size).toBe('compact'); + expect(result.current.parsedCaptcha.language).toBe('el-GR'); + }); + + it('sets the parsedCaptcha correctly from the appearance prop', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAppearance(), { wrapper }); + expect(result.current.parsedCaptcha.theme).toBe('dark'); + expect(result.current.parsedCaptcha.size).toBe('compact'); + expect(result.current.parsedCaptcha.language).toBe('el-GR'); + }); + + it('sets the parsedLayout correctly from the globalAppearance and appearance prop', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAppearance(), { wrapper }); + expect(result.current.parsedCaptcha.theme).toBe('dark'); + expect(result.current.parsedCaptcha.size).toBe('compact'); + expect(result.current.parsedCaptcha.language).toBe('el-GR'); + }); + + it('uses the default values when no captcha property is passed', () => { + const wrapper = ({ children }) => {children}; + + const { result } = renderHook(() => useAppearance(), { wrapper }); + expect(result.current.parsedCaptcha.theme).toBe('auto'); + expect(result.current.parsedCaptcha.size).toBe('normal'); + expect(result.current.parsedCaptcha.language).toBe(''); + }); +}); diff --git a/packages/clerk-js/src/ui/customizables/parseAppearance.ts b/packages/clerk-js/src/ui/customizables/parseAppearance.ts index 1c5f32c5526..f01f8b4cb6a 100644 --- a/packages/clerk-js/src/ui/customizables/parseAppearance.ts +++ b/packages/clerk-js/src/ui/customizables/parseAppearance.ts @@ -1,5 +1,5 @@ import { fastDeepMergeAndReplace } from '@clerk/shared/utils'; -import type { Appearance, DeepPartial, Elements, Layout, Theme } from '@clerk/types'; +import type { Appearance, CaptchaAppearanceOptions, DeepPartial, Elements, Layout, Theme } from '@clerk/types'; import { createInternalTheme, defaultInternalTheme } from '../foundations'; import { polishedAppearance } from '../polishedAppearance'; @@ -16,8 +16,12 @@ import { export type ParsedElements = Elements[]; export type ParsedInternalTheme = InternalTheme; export type ParsedLayout = Required; +export type ParsedCaptcha = Required; -type PublicAppearanceTopLevelKey = keyof Omit; +type PublicAppearanceTopLevelKey = keyof Omit< + Appearance, + 'baseTheme' | 'elements' | 'layout' | 'variables' | 'captcha' +>; export type AppearanceCascade = { globalAppearance?: Appearance; @@ -29,6 +33,7 @@ export type ParsedAppearance = { parsedElements: ParsedElements; parsedInternalTheme: ParsedInternalTheme; parsedLayout: ParsedLayout; + parsedCaptcha: ParsedCaptcha; }; const defaultLayout: ParsedLayout = { @@ -46,6 +51,12 @@ const defaultLayout: ParsedLayout = { unsafe_disableDevelopmentModeWarnings: false, }; +const defaultCaptchaOptions: ParsedCaptcha = { + theme: 'auto', + size: 'normal', + language: '', +}; + /** * Parses the public appearance object. * It splits the resulting styles into 2 objects: parsedElements, parsedInternalTheme @@ -63,6 +74,7 @@ export const parseAppearance = (cascade: AppearanceCascade): ParsedAppearance => const parsedInternalTheme = parseVariables(appearanceList); const parsedLayout = parseLayout(appearanceList); + const parsedCaptcha = parseCaptcha(appearanceList); if ( !appearanceList.find(a => { @@ -83,7 +95,7 @@ export const parseAppearance = (cascade: AppearanceCascade): ParsedAppearance => return res; }), ); - return { parsedElements, parsedInternalTheme, parsedLayout }; + return { parsedElements, parsedInternalTheme, parsedLayout, parsedCaptcha }; }; const expand = (theme: Theme | undefined, cascade: any[]) => { @@ -106,6 +118,13 @@ const parseLayout = (appearanceList: Appearance[]) => { return { ...defaultLayout, ...appearanceList.reduce((acc, appearance) => ({ ...acc, ...appearance.layout }), {}) }; }; +const parseCaptcha = (appearanceList: Appearance[]) => { + return { + ...defaultCaptchaOptions, + ...appearanceList.reduce((acc, appearance) => ({ ...acc, ...appearance.captcha }), {}), + }; +}; + const parseVariables = (appearances: Appearance[]) => { const res = {} as DeepPartial; fastDeepMergeAndReplace({ ...defaultInternalTheme }, res); diff --git a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx index 7a5a5408f20..29e040c79fc 100644 --- a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx +++ b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import { CAPTCHA_ELEMENT_ID } from '../../utils/captcha'; -import { Box } from '../customizables'; +import { Box, useAppearance, useLocalizations } from '../customizables'; /** * This component uses a MutationObserver to listen for DOM changes made by our Turnstile logic, @@ -14,6 +14,11 @@ export const CaptchaElement = () => { const maxHeightValueRef = useRef('0'); const minHeightValueRef = useRef('unset'); const marginBottomValueRef = useRef('unset'); + const { parsedCaptcha } = useAppearance(); + const { locale } = useLocalizations(); + const captchaTheme = parsedCaptcha?.theme; + const captchaSize = parsedCaptcha?.size; + const captchaLanguage = parsedCaptcha?.language || locale; useEffect(() => { if (!elementRef.current) return; @@ -48,6 +53,9 @@ export const CaptchaElement = () => { minHeight: minHeightValueRef.current, marginBottom: marginBottomValueRef.current, }} + data-cl-theme={captchaTheme} + data-cl-size={captchaSize} + data-cl-language={captchaLanguage} /> ); }; diff --git a/packages/clerk-js/src/utils/captcha/turnstile.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts index 984bdfbc5f3..80c0df70526 100644 --- a/packages/clerk-js/src/utils/captcha/turnstile.ts +++ b/packages/clerk-js/src/utils/captcha/turnstile.ts @@ -1,6 +1,6 @@ import { waitForElement } from '@clerk/shared/dom'; import { loadScript } from '@clerk/shared/loadScript'; -import type { CaptchaWidgetType } from '@clerk/types'; +import type { CaptchaAppearanceOptions, CaptchaWidgetType } from '@clerk/types'; import { CAPTCHA_ELEMENT_ID, CAPTCHA_INVISIBLE_CLASSNAME } from './constants'; import type { CaptchaOptions } from './types'; @@ -9,6 +9,12 @@ import type { CaptchaOptions } from './types'; // CF docs: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#disable-implicit-rendering const CLOUDFLARE_TURNSTILE_ORIGINAL_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; +type CaptchaAttributes = { + theme?: RenderOptions['theme']; + language?: RenderOptions['language']; + size: RenderOptions['size']; +}; + interface RenderOptions { /** * Every widget has a sitekey. This sitekey is associated with the corresponding widget configuration and is created upon the widget creation. @@ -58,6 +64,24 @@ interface RenderOptions { * @default 'always' */ appearance?: 'always' | 'execute' | 'interaction-only'; + /** + * The widget theme. Can take the following values: light, dark, auto. + * The default is auto, which respects the user preference. This can be forced to light or dark by setting the theme accordingly. + * @default 'auto' + */ + theme?: CaptchaAppearanceOptions['theme']; + /** + * The widget size. Can take the following values: normal, flexible, compact. + * @default 'normal' + */ + size?: CaptchaAppearanceOptions['size']; + /** + * Language to display, must be either: auto (default) to use the language that the visitor has chosen, + * or an ISO 639-1 two-letter language code (e.g. en) or language and country code (e.g. en-US). + * Refer to the list of supported languages for more information. + * https://developers.cloudflare.com/turnstile/reference/supported-languages + */ + language?: CaptchaAppearanceOptions['language']; /** * A custom value that can be used to differentiate widgets under the same sitekey * in analytics and which is returned upon validation. This can only contain up to @@ -109,6 +133,14 @@ async function loadCaptchaFromCloudflareURL() { } } +function getCaptchaAttibutesFromElemenet(element: HTMLElement): CaptchaAttributes { + const theme = (element.getAttribute('data-cl-theme') as RenderOptions['theme']) || undefined; + const language = (element.getAttribute('data-cl-language') as RenderOptions['language']) || undefined; + const size = (element.getAttribute('data-cl-size') as RenderOptions['size']) || undefined; + + return { theme, language, size }; +} + /* * How this function works: * The widgetType is either 'invisible' or 'smart'. @@ -125,6 +157,9 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { let captchaToken = ''; let id = ''; let turnstileSiteKey = siteKey; + let captchaTheme: RenderOptions['theme']; + let captchaSize: RenderOptions['size']; + let captchaLanguage: RenderOptions['language']; let retries = 0; let widgetContainerQuerySelector: string | undefined; // The backend uses this to determine which Turnstile site-key was used in order to verify the token @@ -138,7 +173,13 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { captchaWidgetType = widgetType; widgetContainerQuerySelector = modalContainerQuerySelector; await openModal?.(); - await waitForElement(modalContainerQuerySelector); + const modalContainderEl = await waitForElement(modalContainerQuerySelector); + if (modalContainderEl) { + const { theme, language, size } = getCaptchaAttibutesFromElemenet(modalContainderEl); + captchaTheme = theme; + captchaLanguage = language; + captchaSize = size; + } } // smart widget with container provided by user @@ -148,6 +189,10 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { captchaWidgetType = 'smart'; widgetContainerQuerySelector = `#${CAPTCHA_ELEMENT_ID}`; visibleDiv.style.maxHeight = '0'; // This is to prevent the layout shift when the render method is called + const { theme, language, size } = getCaptchaAttibutesFromElemenet(visibleDiv); + captchaTheme = theme; + captchaLanguage = language; + captchaSize = size; } else { console.error( 'Cannot initialize Smart CAPTCHA widget because the `clerk-captcha` DOM element was not found; falling back to Invisible CAPTCHA widget. If you are using a custom flow, visit https://clerk.com/docs/custom-flows/bot-sign-up-protection for instructions', @@ -172,6 +217,9 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { const id = captcha.render(widgetContainerQuerySelector, { sitekey: turnstileSiteKey, appearance: 'interaction-only', + theme: captchaTheme || 'auto', + size: captchaSize || 'normal', + language: captchaLanguage || 'auto', action: opts.action, retry: 'never', 'refresh-expired': 'auto', @@ -192,7 +240,7 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => { // We set the min-height to the height of the Turnstile widget // because the widget initially does a small layout shift // and then expands to the correct height - visibleWidget.style.minHeight = '68px'; + visibleWidget.style.minHeight = captchaSize === 'compact' ? '140px' : '68px'; visibleWidget.style.marginBottom = '1.5rem'; } } diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 73d2e73b487..e086a68f729 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -551,6 +551,12 @@ export type Theme = { * Eg: `formButtonPrimary__loading: { backgroundColor: 'gray' }` */ elements?: Elements; + /** + * The appearance of the CAPTCHA widget. + * This will be used to style the CAPTCHA widget. + * Eg: `theme: 'dark'` + */ + captcha?: CaptchaAppearanceOptions; }; export type Layout = { @@ -632,6 +638,24 @@ export type Layout = { unsafe_disableDevelopmentModeWarnings?: boolean; }; +export type CaptchaAppearanceOptions = { + /** + * The widget theme. Can take the following values: light, dark, auto. + * @default 'auto' + */ + theme?: 'auto' | 'light' | 'dark'; + /** + * The widget size. Can take the following values: normal, flexible, compact. + * @default 'normal' + */ + size?: 'normal' | 'flexible' | 'compact'; + /** + * Language to display, must be either: auto (default) to use the language that the visitor has chosen, or an ISO 639-1 two-letter language code (e.g. en) or language and country code (e.g. en-US). + * Refer to the list of supported languages for more information: https://developers.cloudflare.com/turnstile/reference/supported-languages + */ + language?: string; +}; + export type SignInTheme = Theme; export type SignUpTheme = Theme; export type UserButtonTheme = Theme;