diff --git a/.changeset/eighty-pens-mix.md b/.changeset/eighty-pens-mix.md new file mode 100644 index 00000000000..4130381b42e --- /dev/null +++ b/.changeset/eighty-pens-mix.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Add support for different CAPTCHA widget types diff --git a/packages/clerk-js/src/core/resources/DisplayConfig.ts b/packages/clerk-js/src/core/resources/DisplayConfig.ts index f172acab4a8..222aad45fc6 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -1,4 +1,10 @@ -import type { DisplayConfigJSON, DisplayConfigResource, DisplayThemeJSON, PreferredSignInStrategy } from '@clerk/types'; +import type { + CaptchaWidgetType, + DisplayConfigJSON, + DisplayConfigResource, + DisplayThemeJSON, + PreferredSignInStrategy, +} from '@clerk/types'; import { BaseResource } from './internal'; @@ -14,6 +20,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource backendHost!: string; branded!: boolean; captchaPublicKey: string | null = null; + captchaWidgetType: CaptchaWidgetType = null; homeUrl!: string; instanceEnvironmentType!: string; faviconImageUrl!: string; @@ -59,6 +66,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource this.afterSwitchSessionUrl = data.after_switch_session_url; this.branded = data.branded; this.captchaPublicKey = data.captcha_public_key; + this.captchaWidgetType = data.captcha_widget_type; this.supportEmail = data.support_email || ''; this.clerkJSVersion = data.clerk_js_version; this.organizationProfileUrl = data.organization_profile_url; diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index d40040daf5d..7d1def63bb8 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -68,12 +68,13 @@ export class SignUp extends BaseResource implements SignUpResource { create = async (params: SignUpCreateParams): Promise => { const paramsWithCaptcha: Record = params; - const { captchaSiteKey, canUseCaptcha, captchaURL } = retrieveCaptchaInfo(SignUp.clerk); + const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType } = retrieveCaptchaInfo(SignUp.clerk); if (canUseCaptcha && captchaSiteKey && captchaURL) { try { paramsWithCaptcha.captchaToken = await getCaptchaToken({ siteKey: captchaSiteKey, + widgetType: captchaWidgetType, scriptUrl: captchaURL, }); } catch (e) { diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx index dc3979f65b6..bdc685a847c 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Col, localizationKeys, useAppearance } from '../../customizables'; import { Form } from '../../elements'; +import { CaptchaElement } from '../../elements/CaptchaElement'; import { mqu } from '../../styledSystem'; import type { FormControlState } from '../../utils'; import type { ActiveIdentifier, Fields } from './signUpFormHelpers'; @@ -101,10 +102,13 @@ export const SignUpForm = (props: SignUpFormProps) => { )} - + + + + ); }; diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index b361a5c7fc9..0bf696be77d 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -13,6 +13,7 @@ import { SocialButtonsReversibleContainerWithDivider, withCardStateProvider, } from '../../elements'; +import { CaptchaElement } from '../../elements/CaptchaElement'; import { useCardState } from '../../elements/contexts'; import { useLoadingStatus } from '../../hooks'; import { useRouter } from '../../router'; @@ -277,6 +278,7 @@ function _SignUpStart(): JSX.Element { /> )} + {!shouldShowForm && } diff --git a/packages/clerk-js/src/ui/elements/CaptchaElement.tsx b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx new file mode 100644 index 00000000000..ee8772cf7c6 --- /dev/null +++ b/packages/clerk-js/src/ui/elements/CaptchaElement.tsx @@ -0,0 +1,9 @@ +import { CAPTCHA_ELEMENT_ID } from '../../utils'; +import { Box } from '../customizables'; + +export const CaptchaElement = () => ( + ({ display: 'none', marginBottom: t.space.$6 })} + /> +); diff --git a/packages/clerk-js/src/utils/captcha.ts b/packages/clerk-js/src/utils/captcha.ts index 1c10f1eb695..60b9ebe116c 100644 --- a/packages/clerk-js/src/utils/captcha.ts +++ b/packages/clerk-js/src/utils/captcha.ts @@ -1,4 +1,5 @@ import { loadScript } from '@clerk/shared/loadScript'; +import type { CaptchaWidgetType } from '@clerk/types'; import { clerkFailedToLoadThirdPartyScript } from '../core/errors'; @@ -35,6 +36,14 @@ interface RenderOptions { * @param errorCode string */ 'error-callback'?: (errorCode: string) => void; + /** + * Appearance controls when the widget is visible. + * It can be always (default), execute, or interaction-only. + * Refer to Appearance Modes for more information: + * https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#appearance-modes + * @default 'always' + */ + appearance?: 'always' | 'execute' | 'interaction-only'; } interface Turnstile { @@ -50,7 +59,8 @@ declare global { } } -const WIDGET_CLASSNAME = 'clerk-captcha'; +export const CAPTCHA_ELEMENT_ID = 'clerk-captcha'; +export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha'; export const shouldRetryTurnstileErrorCode = (errorCode: string) => { const codesWithRetries = ['crashed', 'undefined_error', '102', '103', '104', '106', '110600', '300', '600']; @@ -70,14 +80,34 @@ export async function loadCaptcha(url: string) { return window.turnstile; } -export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptUrl: string }) => { - const { siteKey: sitekey, scriptUrl } = captchaOptions; +export const getCaptchaToken = async (captchaOptions: { + siteKey: string; + scriptUrl: string; + widgetType: CaptchaWidgetType; +}) => { + const { siteKey: sitekey, scriptUrl, widgetType } = captchaOptions; let captchaToken = '', id = ''; + const invisibleWidget = !widgetType || widgetType === 'invisible'; + + let widgetDiv: HTMLElement | null = null; - const div = document.createElement('div'); - div.classList.add(WIDGET_CLASSNAME); - document.body.appendChild(div); + if (invisibleWidget) { + const div = document.createElement('div'); + div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); + document.body.appendChild(div); + widgetDiv = div; + } else { + const visibleDiv = document.getElementById(CAPTCHA_ELEMENT_ID); + if (visibleDiv) { + visibleDiv.style.display = 'block'; + widgetDiv = visibleDiv; + } else { + throw { + captchaError: 'Element to render the captcha not found', + }; + } + } const captcha = await loadCaptcha(scriptUrl); let retries = 0; @@ -86,8 +116,9 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU const handleCaptchaTokenGeneration = (): Promise<[string, string]> => { return new Promise((resolve, reject) => { try { - const id = captcha.render(`.${WIDGET_CLASSNAME}`, { + const id = captcha.render(invisibleWidget ? `.${CAPTCHA_INVISIBLE_CLASSNAME}` : `#${CAPTCHA_ELEMENT_ID}`, { sitekey, + appearance: widgetType === 'always_visible' ? 'always' : 'interaction-only', retry: 'never', 'refresh-expired': 'auto', callback: function (token: string) { @@ -133,8 +164,11 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU captchaError: e, }; } finally { - // After challenge has run remove node element attached - document.body.removeChild(div); + if (invisibleWidget) { + document.body.removeChild(widgetDiv); + } else { + widgetDiv.style.display = 'none'; + } } return captchaToken; diff --git a/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts b/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts index 0948d483a58..d83f4a4d126 100644 --- a/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts +++ b/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts @@ -6,6 +6,7 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => { const fapiClient = createFapiClient(clerk); return { captchaSiteKey: _environment ? _environment.displayConfig.captchaPublicKey : null, + captchaWidgetType: _environment ? _environment.displayConfig.captchaWidgetType : null, canUseCaptcha: _environment ? _environment.userSettings.signUp.captcha_enabled && clerk.isStandardBrowser && diff --git a/packages/types/src/displayConfig.ts b/packages/types/src/displayConfig.ts index 36228251d0f..c7039c82ef1 100644 --- a/packages/types/src/displayConfig.ts +++ b/packages/types/src/displayConfig.ts @@ -2,6 +2,7 @@ import type { DisplayThemeJSON } from './json'; import type { ClerkResource } from './resource'; export type PreferredSignInStrategy = 'password' | 'otp'; +export type CaptchaWidgetType = 'smart' | 'always_visible' | 'invisible' | null; export interface DisplayConfigJSON { object: 'display_config'; @@ -14,6 +15,7 @@ export interface DisplayConfigJSON { application_name: string; branded: boolean; captcha_public_key: string | null; + captcha_widget_type: CaptchaWidgetType; home_url: string; instance_environment_type: string; logo_image_url: string; @@ -42,6 +44,7 @@ export interface DisplayConfigResource extends ClerkResource { backendHost: string; branded: boolean; captchaPublicKey: string | null; + captchaWidgetType: CaptchaWidgetType; homeUrl: string; instanceEnvironmentType: string; logoImageUrl: string;