From bf68ff000a0dba430cf0cd356fc9297687027d0e Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Thu, 18 Apr 2024 13:12:11 +0300 Subject: [PATCH 1/2] feat(clerkjs): Add support for different CAPTCHA widget types --- .changeset/eighty-pens-mix.md | 6 ++ .../src/core/resources/DisplayConfig.ts | 12 ++- .../clerk-js/src/core/resources/SignUp.ts | 11 ++- .../clerk-js/src/ui/common/SSOCallback.tsx | 2 + .../src/ui/components/SignUp/SignUpForm.tsx | 8 +- .../src/ui/components/SignUp/SignUpStart.tsx | 2 + .../src/ui/elements/CaptchaElement.tsx | 9 +++ packages/clerk-js/src/utils/captcha.ts | 78 ++++++++++++++++--- .../clerk-js/src/utils/retrieveCaptchaInfo.ts | 2 + packages/types/src/displayConfig.ts | 5 ++ 10 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 .changeset/eighty-pens-mix.md create mode 100644 packages/clerk-js/src/ui/elements/CaptchaElement.tsx 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 16c21187c7c..81d0bb12fd9 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -1,5 +1,11 @@ import { deprecatedProperty } from '@clerk/shared/deprecated'; -import type { DisplayConfigJSON, DisplayConfigResource, DisplayThemeJSON, PreferredSignInStrategy } from '@clerk/types'; +import type { + CaptchaWidgetType, + DisplayConfigJSON, + DisplayConfigResource, + DisplayThemeJSON, + PreferredSignInStrategy, +} from '@clerk/types'; import { BaseResource } from './internal'; @@ -15,6 +21,8 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource backendHost!: string; branded!: boolean; captchaPublicKey: string | null = null; + captchaWidgetType: CaptchaWidgetType = null; + captchaPublicKeyInvisible: string | null = null; homeUrl!: string; instanceEnvironmentType!: string; faviconImageUrl!: string; @@ -71,6 +79,8 @@ 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.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 7666fc0a848..c5b134e211f 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -71,14 +71,19 @@ 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, 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/ui/common/SSOCallback.tsx b/packages/clerk-js/src/ui/common/SSOCallback.tsx index 1284fed623b..fcc970b60e3 100644 --- a/packages/clerk-js/src/ui/common/SSOCallback.tsx +++ b/packages/clerk-js/src/ui/common/SSOCallback.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useCoreClerk } from '../contexts'; import { Flow } from '../customizables'; import { Card, CardAlert, LoadingCardContainer, useCardState, withCardStateProvider } from '../elements'; +import { CaptchaElement } from '../elements/CaptchaElement'; import { useRouter } from '../router'; import { handleError } from '../utils'; @@ -35,6 +36,7 @@ export const SSOCallbackCard = (props: HandleOAuthCallbackParams | HandleSamlCal {card.error} + ); diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx index a0c584b0489..2bade99941a 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpForm.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { useAppearance } from '../../customizables'; +import { Col, useAppearance } from '../../customizables'; import { Form } from '../../elements'; +import { CaptchaElement } from '../../elements/CaptchaElement'; import type { FormControlState } from '../../utils'; import type { ActiveIdentifier, Fields } from './signUpFormHelpers'; @@ -99,7 +100,10 @@ export const SignUpForm = (props: SignUpFormProps) => { /> )} - Continue + + + Continue + ); }; diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index 8259a9f80b7..2101fa04836 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -14,6 +14,7 @@ import { SocialButtonsReversibleContainerWithDivider, withCardStateProvider, } from '../../elements'; +import { CaptchaElement } from '../../elements/CaptchaElement'; import { useCardState } from '../../elements/contexts'; import { useLoadingStatus } from '../../hooks'; import { useRouter } from '../../router'; @@ -272,6 +273,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..22ccd9d40f7 --- /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, alignSelf: 'center' })} + /> +); diff --git a/packages/clerk-js/src/utils/captcha.ts b/packages/clerk-js/src/utils/captcha.ts index 1c10f1eb695..c6b77624bfd 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,18 @@ 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. + * 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 +63,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 +84,48 @@ export async function loadCaptcha(url: string) { return window.turnstile; } -export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptUrl: string }) => { - const { siteKey: sitekey, scriptUrl } = captchaOptions; +/* + * 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, scriptUrl, widgetType, invisibleSiteKey } = captchaOptions; let captchaToken = '', id = ''; + let invisibleWidget = !widgetType || widgetType === 'invisible'; + let turnstileSiteKey = siteKey; + + let widgetDiv: HTMLElement | null = null; - const div = document.createElement('div'); - div.classList.add(WIDGET_CLASSNAME); - document.body.appendChild(div); + const createInvisibleDOMElement = () => { + const div = document.createElement('div'); + div.classList.add(CAPTCHA_INVISIBLE_CLASSNAME); + document.body.appendChild(div); + return div; + }; + + if (invisibleWidget) { + widgetDiv = createInvisibleDOMElement(); + } else { + const visibleDiv = document.getElementById(CAPTCHA_ELEMENT_ID); + if (visibleDiv) { + visibleDiv.style.display = 'block'; + widgetDiv = visibleDiv; + } else { + console.error('Captcha DOM element not found. Using invisible captcha widget.'); + widgetDiv = createInvisibleDOMElement(); + invisibleWidget = true; + turnstileSiteKey = invisibleSiteKey; + } + } const captcha = await loadCaptcha(scriptUrl); let retries = 0; @@ -86,8 +134,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}`, { - sitekey, + const id = captcha.render(invisibleWidget ? `.${CAPTCHA_INVISIBLE_CLASSNAME}` : `#${CAPTCHA_ELEMENT_ID}`, { + sitekey: turnstileSiteKey, + appearance: 'interaction-only', retry: 'never', 'refresh-expired': 'auto', callback: function (token: string) { @@ -108,6 +157,10 @@ export const getCaptchaToken = async (captchaOptions: { siteKey: string; scriptU } reject([errorCodes.join(','), id]); }, + 'unsupported-callback': function () { + reject(['This browser is not supported by the CAPTCHA.', id]); + return true; + }, }); } catch (e) { /** @@ -133,9 +186,12 @@ 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; + 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 d7450851bc6..3abd049c902 100644 --- a/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts +++ b/packages/clerk-js/src/utils/retrieveCaptchaInfo.ts @@ -6,6 +6,8 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => { const fapiClient = createFapiClient(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 61c5d7e1ae2..92fbbf8f586 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' | 'invisible' | null; export interface DisplayConfigJSON { object: 'display_config'; @@ -14,6 +15,8 @@ export interface DisplayConfigJSON { application_name: string; branded: boolean; captcha_public_key: string | null; + captcha_widget_type: CaptchaWidgetType; + captcha_public_key_invisible: string | null; home_url: string; instance_environment_type: string; /* @deprecated */ @@ -47,6 +50,8 @@ export interface DisplayConfigResource extends ClerkResource { backendHost: string; branded: boolean; captchaPublicKey: string | null; + captchaWidgetType: CaptchaWidgetType; + captchaPublicKeyInvisible: string | null; homeUrl: string; instanceEnvironmentType: string; logoImageUrl: string; From 1f72c83a099bd9cbe26f50dc96a1350db3526401 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Tue, 16 Apr 2024 19:03:30 +0300 Subject: [PATCH 2/2] chore(clerk-js): Fix changeset wording --- .changeset/eighty-pens-mix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/eighty-pens-mix.md b/.changeset/eighty-pens-mix.md index 4130381b42e..6d702056c25 100644 --- a/.changeset/eighty-pens-mix.md +++ b/.changeset/eighty-pens-mix.md @@ -3,4 +3,4 @@ '@clerk/types': minor --- -Add support for different CAPTCHA widget types +Add support for different Bot Protection widget types