diff --git a/.changeset/itchy-onions-rush.md b/.changeset/itchy-onions-rush.md new file mode 100644 index 00000000000..232f48f871d --- /dev/null +++ b/.changeset/itchy-onions-rush.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Fallback to invisible CAPTCHA if the element to render to is not found in the DOM diff --git a/packages/clerk-js/src/core/resources/DisplayConfig.ts b/packages/clerk-js/src/core/resources/DisplayConfig.ts index e06cf41c655..8d776b1122e 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -21,6 +21,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource branded!: boolean; captchaPublicKey: string | null = null; captchaWidgetType: CaptchaWidgetType = null; + captchaPublicKeyInvisible: string | null = null; homeUrl!: string; instanceEnvironmentType!: string; faviconImageUrl!: string; @@ -68,6 +69,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource this.branded = data.branded; this.captchaPublicKey = data.captcha_public_key; this.captchaWidgetType = data.captcha_widget_type; + this.captchaPublicKeyInvisible = data.captcha_public_key_invisible; 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 7d1def63bb8..885699fc9c2 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -68,15 +68,19 @@ export class SignUp extends BaseResource implements SignUpResource { create = async (params: SignUpCreateParams): Promise => { const paramsWithCaptcha: Record = params; - const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType } = retrieveCaptchaInfo(SignUp.clerk); + const { captchaSiteKey, canUseCaptcha, captchaURL, captchaWidgetType, captchaPublicKeyInvisible } = + retrieveCaptchaInfo(SignUp.clerk); - if (canUseCaptcha && captchaSiteKey && captchaURL) { + if (canUseCaptcha && captchaSiteKey && captchaURL && captchaPublicKeyInvisible) { try { - paramsWithCaptcha.captchaToken = await getCaptchaToken({ + const { captchaToken, captchaWidgetTypeUsed } = await getCaptchaToken({ siteKey: captchaSiteKey, widgetType: captchaWidgetType, + invisibleSiteKey: captchaPublicKeyInvisible, scriptUrl: captchaURL, }); + paramsWithCaptcha.captchaToken = captchaToken; + paramsWithCaptcha.captchaWidgetType = captchaWidgetTypeUsed; } catch (e) { if (e.captchaError) { paramsWithCaptcha.captchaError = e.captchaError; diff --git a/packages/clerk-js/src/utils/captcha.ts b/packages/clerk-js/src/utils/captcha.ts index 60b9ebe116c..c6b77624bfd 100644 --- a/packages/clerk-js/src/utils/captcha.ts +++ b/packages/clerk-js/src/utils/captcha.ts @@ -36,6 +36,10 @@ interface RenderOptions { * @param errorCode string */ 'error-callback'?: (errorCode: string) => void; + /** + * A JavaScript callback invoked when a given client/browser is not supported by the widget. + */ + 'unsupported-callback'?: () => boolean; /** * Appearance controls when the widget is visible. * It can be always (default), execute, or interaction-only. @@ -80,32 +84,46 @@ export async function loadCaptcha(url: string) { return window.turnstile; } +/* + * How this function works: + * The widgetType is either 'invisible' or 'smart'. + * - If the widgetType is 'invisible', the captcha widget is rendered in a hidden div at the bottom of the body. + * - If the widgetType is 'smart', the captcha widget is rendered in a div with the id 'clerk-captcha'. If the div does + * not exist, the invisibleSiteKey is used as a fallback and the widget is rendered in a hidden div at the bottom of the body. + */ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptUrl: string; widgetType: CaptchaWidgetType; + invisibleSiteKey: string; }) => { - const { siteKey: sitekey, scriptUrl, widgetType } = captchaOptions; + const { siteKey, scriptUrl, widgetType, invisibleSiteKey } = captchaOptions; let captchaToken = '', id = ''; - const invisibleWidget = !widgetType || widgetType === 'invisible'; + let invisibleWidget = !widgetType || widgetType === 'invisible'; + let turnstileSiteKey = siteKey; let widgetDiv: HTMLElement | null = null; - if (invisibleWidget) { + const createInvisibleDOMElement = () => { const div = document.createElement('div'); div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); document.body.appendChild(div); - widgetDiv = div; + return div; + }; + + if (invisibleWidget) { + widgetDiv = createInvisibleDOMElement(); } 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', - }; + console.error('Captcha DOM element not found. Using invisible captcha widget.'); + widgetDiv = createInvisibleDOMElement(); + invisibleWidget = true; + turnstileSiteKey = invisibleSiteKey; } } @@ -117,8 +135,8 @@ export const getCaptchaToken = async (captchaOptions: { return new Promise((resolve, reject) => { try { const id = captcha.render(invisibleWidget ? `.${CAPTCHA_INVISIBLE_CLASSNAME}` : `#${CAPTCHA_ELEMENT_ID}`, { - sitekey, - appearance: widgetType === 'always_visible' ? 'always' : 'interaction-only', + sitekey: turnstileSiteKey, + appearance: 'interaction-only', retry: 'never', 'refresh-expired': 'auto', callback: function (token: string) { @@ -139,6 +157,10 @@ export const getCaptchaToken = async (captchaOptions: { } reject([errorCodes.join(','), id]); }, + 'unsupported-callback': function () { + reject(['This browser is not supported by the CAPTCHA.', id]); + return true; + }, }); } catch (e) { /** @@ -171,5 +193,5 @@ export const getCaptchaToken = async (captchaOptions: { } } - return captchaToken; + return { captchaToken, captchaWidgetTypeUsed: invisibleWidget ? 'invisible' : 'smart' }; }; diff --git a/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts b/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts index d83f4a4d126..ca4d4eb5d83 100644 --- a/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts +++ b/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts @@ -7,6 +7,7 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => { return { captchaSiteKey: _environment ? _environment.displayConfig.captchaPublicKey : null, captchaWidgetType: _environment ? _environment.displayConfig.captchaWidgetType : null, + captchaPublicKeyInvisible: _environment ? _environment.displayConfig.captchaPublicKeyInvisible : 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 c60f6f40714..ab923e76cc4 100644 --- a/packages/types/src/displayConfig.ts +++ b/packages/types/src/displayConfig.ts @@ -2,7 +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 type CaptchaWidgetType = 'smart' | 'invisible' | null; export interface DisplayConfigJSON { object: 'display_config'; @@ -16,6 +16,7 @@ export interface DisplayConfigJSON { branded: boolean; captcha_public_key: string | null; captcha_widget_type: CaptchaWidgetType; + captcha_public_key_invisible: string | null; home_url: string; instance_environment_type: string; logo_image_url: string; @@ -46,6 +47,7 @@ export interface DisplayConfigResource extends ClerkResource { branded: boolean; captchaPublicKey: string | null; captchaWidgetType: CaptchaWidgetType; + captchaPublicKeyInvisible: string | null; homeUrl: string; instanceEnvironmentType: string; logoImageUrl: string;