Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ef61a8a
feat(clerk-js): Introduce layout configuration options for Turnstile …
anagstef Feb 17, 2025
414dfb4
Use dark captcha for the dark and shadesOfPurple themes
anagstef Feb 18, 2025
8ce08e0
Merge branch 'main' into stefanos/fraud-377-expose-more-options-for-t…
anagstef Feb 18, 2025
21ef19a
fix default captcha language
anagstef Feb 19, 2025
40e936e
Merge branch 'main' into stefanos/fraud-377-expose-more-options-for-t…
anagstef Feb 19, 2025
521afc3
use auto for language default
anagstef Feb 19, 2025
5ec09c5
Update packages/clerk-js/src/utils/captcha/turnstile.ts
anagstef Feb 20, 2025
08167d6
Update packages/clerk-js/src/utils/captcha/turnstile.ts
anagstef Feb 20, 2025
080cbd9
change default theme to 'auto'
anagstef Feb 20, 2025
55b6a53
Adjust min-height of Turnstile widget if captcha size is `compact`
anagstef Feb 20, 2025
6bc3227
Merge branch 'main' into stefanos/fraud-377-expose-more-options-for-t…
anagstef Feb 21, 2025
c81d99d
Merge branch 'main' into stefanos/fraud-377-expose-more-options-for-t…
anagstef Feb 21, 2025
6008795
Merge branch 'main' into stefanos/fraud-377-expose-more-options-for-t…
anagstef Feb 25, 2025
69b7ff0
Refactor the captcha properties to exist directly under the appearance
anagstef Feb 26, 2025
2db9a50
add tests for parsedCaptcha
anagstef Feb 26, 2025
1f80c34
Merge branch 'main' into stefanos/fraud-377-expose-more-options-for-t…
anagstef Feb 26, 2025
8bf643b
increase bundlewatch maxSize
anagstef Feb 26, 2025
4a482c7
Update .changeset/fluffy-hairs-thank.md
anagstef Feb 26, 2025
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
6 changes: 6 additions & 0 deletions .changeset/fluffy-hairs-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Introduce the `appearance.captcha` property for the CAPTCHA widget
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
15 changes: 13 additions & 2 deletions packages/clerk-js/src/ui/components/BlankCaptchaModal/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card.Root>
<Card.Content>
<div id='cl-modal-captcha-container' />
<div
id='cl-modal-captcha-container'
data-cl-theme={captchaTheme}
data-cl-size={captchaSize}
data-cl-language={captchaLanguage}
/>
</Card.Content>
</Card.Root>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<AppearanceProvider
appearanceKey='signIn'
globalAppearance={{
captcha: {
theme: 'dark',
size: 'compact',
language: 'el-GR',
},
}}
>
{children}
</AppearanceProvider>
);

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 }) => (
<AppearanceProvider
appearanceKey='signIn'
appearance={{
captcha: {
theme: 'dark',
size: 'compact',
language: 'el-GR',
},
}}
>
{children}
</AppearanceProvider>
);

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 }) => (
<AppearanceProvider
appearanceKey='signIn'
globalAppearance={{
captcha: {
theme: 'light',
size: 'flexible',
language: 'en-US',
},
}}
appearance={{
captcha: {
theme: 'dark',
size: 'compact',
language: 'el-GR',
},
}}
>
{children}
</AppearanceProvider>
);

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 }) => <AppearanceProvider appearanceKey='signIn'>{children}</AppearanceProvider>;

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('');
});
});
25 changes: 22 additions & 3 deletions packages/clerk-js/src/ui/customizables/parseAppearance.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,8 +16,12 @@ import {
export type ParsedElements = Elements[];
export type ParsedInternalTheme = InternalTheme;
export type ParsedLayout = Required<Layout>;
export type ParsedCaptcha = Required<CaptchaAppearanceOptions>;

type PublicAppearanceTopLevelKey = keyof Omit<Appearance, 'baseTheme' | 'elements' | 'layout' | 'variables'>;
type PublicAppearanceTopLevelKey = keyof Omit<
Appearance,
'baseTheme' | 'elements' | 'layout' | 'variables' | 'captcha'
>;

export type AppearanceCascade = {
globalAppearance?: Appearance;
Expand All @@ -29,6 +33,7 @@ export type ParsedAppearance = {
parsedElements: ParsedElements;
parsedInternalTheme: ParsedInternalTheme;
parsedLayout: ParsedLayout;
parsedCaptcha: ParsedCaptcha;
};

const defaultLayout: ParsedLayout = {
Expand All @@ -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
Expand All @@ -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 => {
Expand All @@ -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[]) => {
Expand All @@ -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<InternalTheme>;
fastDeepMergeAndReplace({ ...defaultInternalTheme }, res);
Expand Down
10 changes: 9 additions & 1 deletion packages/clerk-js/src/ui/elements/CaptchaElement.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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}
/>
);
};
54 changes: 51 additions & 3 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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';
}
}
Expand Down
24 changes: 24 additions & 0 deletions packages/types/src/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down
Loading