diff --git a/www/apps/api-reference/app/globals.css b/www/apps/api-reference/app/globals.css index 88cbdb7db188d..c06802320a6c6 100644 --- a/www/apps/api-reference/app/globals.css +++ b/www/apps/api-reference/app/globals.css @@ -50,4 +50,8 @@ body[data-modal="opened"] { @apply !overflow-hidden; } +} + +.grecaptcha-badge { + visibility: hidden; } \ No newline at end of file diff --git a/www/packages/docs-ui/package.json b/www/packages/docs-ui/package.json index 50a06b94376d6..fd851834ae45d 100644 --- a/www/packages/docs-ui/package.json +++ b/www/packages/docs-ui/package.json @@ -68,7 +68,6 @@ "mermaid": "^10.9.0", "npm-to-yarn": "^2.1.0", "prism-react-renderer": "2.3.1", - "react-google-recaptcha": "^3.1.0", "react-instantsearch": "^7.0.3", "react-markdown": "^8.0.7", "react-medium-image-zoom": "^5.1.10", diff --git a/www/packages/docs-ui/src/components/AiAssistant/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/index.tsx index 9c9803fe4e52b..cde748ad1251c 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/index.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/index.tsx @@ -393,6 +393,7 @@ export const AiAssistant = () => { apply } + clickable={true} >
( + promise: Promise, + timeout: number +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("Promise timed out.")) + }, timeout) + + promise + .then((result) => { + clearTimeout(timer) + resolve(result) + }) + .catch((error) => { + clearTimeout(timer) + reject(error) + }) + }) +} + +declare global { + interface Window { + grecaptcha: { + enterprise: { + execute: (id: string, action: { action: string }) => Promise + ready: (callback: () => void) => void + } + } + } +} + +const RECAPTCHA_SCRIPT_ID = "kapa-recaptcha-script" + +/** + * Recaptcha action types to classify recaptcha assessments. + * IMPORTANT: Make sure these match the ones on the widget-proxy + */ +export enum RecaptchaAction { + AskAi = "ask_ai", // for /chat (/query) routes + FeedbackSubmit = "feedback_submit", // for /feedback routes + Search = "search", // for /search routes +} + +type UseRecaptchaProps = { + siteKey: string +} + +/** + * This hook loads the reCAPTCHA SDK and exposes the "grecaptcha.execute" function + * which returns a recpatcha token. The token must then be validated on the backend. + * We use a reCAPTCHA Enterprise Score-based key, which is returning a score when + * calling the reCAPTCHA Enterprise API with the returned token from the `execute` + * call. The score indicates the probability of the request being made by a human. + * @param siteKey the reCAPTCHA (enterprise) site key + * @param loadScript boolean flag to load the reCAPTCHA script + */ +export const useRecaptcha = ({ siteKey }: UseRecaptchaProps) => { + const [isScriptLoaded, setIsScriptLoaded] = useState(false) + // The recaptcha execute function is not immediately + // ready so we need to wait until we can call it. + const [isExecuteReady, setIsExecuteReady] = useState(false) + const isBrowser = useIsBrowser() + + useEffect(() => { + if (!isBrowser) { + return + } + + if (document.getElementById(RECAPTCHA_SCRIPT_ID)) { + setIsScriptLoaded(true) + return + } + + const script = document.createElement("script") + script.id = RECAPTCHA_SCRIPT_ID + script.src = `https://www.google.com/recaptcha/enterprise.js?render=${siteKey}` + script.async = true + script.defer = true + + const handleLoad = () => { + setIsScriptLoaded(true) + } + const handleError = (event: Event) => { + console.error("Failed to load reCAPTCHA Enterprise script", event) + } + + script.addEventListener("load", handleLoad) + script.addEventListener("error", handleError) + + document.head.appendChild(script) + + return () => { + if (script) { + script.removeEventListener("load", handleLoad) + script.removeEventListener("error", handleError) + document.head.removeChild(script) + } + } + }, [siteKey, isBrowser]) + + useEffect(() => { + if (isScriptLoaded && window.grecaptcha) { + try { + window.grecaptcha.enterprise.ready(() => { + setIsExecuteReady(true) + }) + } catch (error) { + console.error("Error during reCAPTCHA ready initialization:", error) + } + } + }, [isScriptLoaded]) + + const execute = useCallback( + async (actionName: RecaptchaAction): Promise => { + if (!isExecuteReady) { + console.error("reCAPTCHA is not ready") + return "" + } + + try { + const token = await executeWithTimeout( + window.grecaptcha.enterprise.execute(siteKey, { + action: actionName, + }), + 4000 + ) + return token + } catch (error) { + console.error("Error obtaining reCAPTCHA token:", error) + return "" + } + }, + [isExecuteReady, siteKey] + ) + + return { execute } +} diff --git a/www/packages/docs-ui/src/providers/AiAssistant/index.tsx b/www/packages/docs-ui/src/providers/AiAssistant/index.tsx index 79ff129fd2739..aa230394b3d4a 100644 --- a/www/packages/docs-ui/src/providers/AiAssistant/index.tsx +++ b/www/packages/docs-ui/src/providers/AiAssistant/index.tsx @@ -3,7 +3,7 @@ import React, { createContext, useContext } from "react" import { useAnalytics } from "@/providers" import { AiAssistant } from "@/components" -import ReCAPTCHA from "react-google-recaptcha" +import { RecaptchaAction, useRecaptcha } from "../../hooks/use-recaptcha" export type AiAssistantFeedbackType = "upvote" | "downvote" @@ -31,18 +31,13 @@ export const AiAssistantProvider = ({ children, }: AiAssistantProviderProps) => { const { analytics } = useAnalytics() - const recaptchaRef = React.createRef() - - const getReCaptchaToken = async () => { - if (recaptchaRef?.current) { - const recaptchaToken = await recaptchaRef.current.executeAsync() - return recaptchaToken || "" - } - return "" - } + const { execute: getReCaptchaToken } = useRecaptcha({ + siteKey: recaptchaSiteKey, + }) const sendRequest = async ( apiPath: string, + action: RecaptchaAction, method = "GET", headers?: HeadersInit, body?: BodyInit @@ -50,7 +45,7 @@ export const AiAssistantProvider = ({ return await fetch(`${apiUrl}${apiPath}`, { method, headers: { - "X-RECAPTCHA-TOKEN": await getReCaptchaToken(), + "X-RECAPTCHA-TOKEN": await getReCaptchaToken(action), "X-WEBSITE-ID": websiteId, ...headers, }, @@ -63,7 +58,8 @@ export const AiAssistantProvider = ({ return await sendRequest( threadId ? `/query/v1/thread/${threadId}/stream?query=${questionParam}` - : `/query/v1/stream?query=${questionParam}` + : `/query/v1/stream?query=${questionParam}`, + RecaptchaAction.AskAi ) } @@ -73,6 +69,7 @@ export const AiAssistantProvider = ({ ) => { return await sendRequest( `/query/v1/question-answer/${questionId}/feedback`, + RecaptchaAction.FeedbackSubmit, "POST", { "Content-Type": "application/json", @@ -94,17 +91,6 @@ export const AiAssistantProvider = ({ > {children} - - console.error( - "ReCAPTCHA token not yet configured. Please reach out to the kapa team at founders@kapa.ai to complete the setup." - ) - } - className="grecaptcha-badge" - /> ) } diff --git a/www/yarn.lock b/www/yarn.lock index 57e5f5b67b993..09f7f405ea5ca 100644 --- a/www/yarn.lock +++ b/www/yarn.lock @@ -13595,7 +13595,6 @@ __metadata: prism-react-renderer: 2.3.1 react: ^18.2.0 react-dom: ^18.2.0 - react-google-recaptcha: ^3.1.0 react-instantsearch: ^7.0.3 react-markdown: ^8.0.7 react-medium-image-zoom: ^5.1.10 @@ -16265,7 +16264,7 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.1.0, hoist-non-react-statics@npm:^3.3.0": +"hoist-non-react-statics@npm:^3.1.0": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -21556,7 +21555,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.0.0, prop-types@npm:^15.5.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.0.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -21743,18 +21742,6 @@ __metadata: languageName: node linkType: hard -"react-async-script@npm:^1.2.0": - version: 1.2.0 - resolution: "react-async-script@npm:1.2.0" - dependencies: - hoist-non-react-statics: ^3.3.0 - prop-types: ^15.5.0 - peerDependencies: - react: ">=16.4.1" - checksum: 89450912110c380abc08258ce17d2fb18d31d6b7179a74f6bc504c0761a4ca271edb671e402fa8e5ea4250b5c17fa953af80a9f1c4ebb26c9e81caee8476c903 - languageName: node - linkType: hard - "react-currency-input-field@npm:^3.6.11": version: 3.6.11 resolution: "react-currency-input-field@npm:3.6.11" @@ -21860,18 +21847,6 @@ __metadata: languageName: node linkType: hard -"react-google-recaptcha@npm:^3.1.0": - version: 3.1.0 - resolution: "react-google-recaptcha@npm:3.1.0" - dependencies: - prop-types: ^15.5.0 - react-async-script: ^1.2.0 - peerDependencies: - react: ">=16.4.1" - checksum: 5ecaa6b88f238defd939012cb2671b4cbda59fe03f059158994b8c5215db482412e905eae6a67d23cef220d77cfe0430ab91ce7849d476515cda72f3a8a0a746 - languageName: node - linkType: hard - "react-helmet-async@npm:*, react-helmet-async@npm:^1.3.0": version: 1.3.0 resolution: "react-helmet-async@npm:1.3.0"