diff --git a/frontend/__tests__/components/features/auth-modal.test.tsx b/frontend/__tests__/components/features/auth-modal.test.tsx index f0ccc7ac97..76d389e0a9 100644 --- a/frontend/__tests__/components/features/auth-modal.test.tsx +++ b/frontend/__tests__/components/features/auth-modal.test.tsx @@ -1,6 +1,7 @@ import { screen, waitFor } from "@testing-library/react"; import { it, describe, expect, vi, beforeEach, afterEach } from "vitest"; import userEvent from "@testing-library/user-event"; +import React from "react"; import { AuthModal } from "#/components/features/waitlist/auth-modal"; import AuthService from "#/api/auth-service/auth-service.api"; import { renderWithProviders } from "test-utils"; @@ -17,22 +18,33 @@ vi.mock("#/hooks/use-tracking", () => ({ }), })); -// Mock the useRecaptcha hook -const mockGetRecaptchaResponse = vi.hoisted(() => vi.fn()); -const mockUseRecaptcha = vi.hoisted(() => - vi.fn(() => ({ - recaptchaLoaded: true, - recaptchaError: false, - widgetId: 1, - recaptchaRef: { current: null }, - getRecaptchaResponse: mockGetRecaptchaResponse, - resetRecaptcha: vi.fn(), - })), +// Minimal mock for react-google-recaptcha - just expose executeAsync via ref +type RecaptchaHandle = { + executeAsync: () => Promise; + reset: () => void; +}; +type RecaptchaProps = { sitekey: string; size: string; onError: () => void }; + +const mockExecuteAsync = vi.hoisted(() => + vi.fn().mockResolvedValue("mock-token"), ); -vi.mock("#/hooks/use-recaptcha", () => ({ - useRecaptcha: mockUseRecaptcha, -})); +vi.mock("react-google-recaptcha", () => { + return { + __esModule: true, + default: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ + executeAsync: mockExecuteAsync, + reset: vi.fn(), + })); + return React.createElement("div", { + "data-testid": "recaptcha-widget", + "data-sitekey": props.sitekey, + "data-size": props.size, + }); + }), + }; +}); describe("AuthModal", () => { let verifyRecaptchaSpy: ReturnType; @@ -40,7 +52,7 @@ describe("AuthModal", () => { beforeEach(() => { vi.stubGlobal("location", { href: "" }); verifyRecaptchaSpy = vi.spyOn(AuthService, "verifyRecaptcha"); - mockGetRecaptchaResponse.mockReturnValue(""); + mockExecuteAsync.mockResolvedValue("mock-token"); }); afterEach(() => { @@ -176,12 +188,12 @@ describe("AuthModal", () => { expect(verifyRecaptchaSpy).not.toHaveBeenCalled(); }); - it("should block auth and show error when reCAPTCHA is not completed", async () => { + it("should block auth and show error when reCAPTCHA execution returns no token", async () => { // Arrange const user = userEvent.setup(); vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", "test-site-key"); const mockUrl = "https://github.com/login/oauth/authorize"; - mockGetRecaptchaResponse.mockReturnValue(""); + mockExecuteAsync.mockResolvedValue(null); renderWithProviders( { await user.click(githubButton); // Assert - expect(window.location.href).toBe(""); + await waitFor(() => { + expect(window.location.href).toBe(""); + }); expect(screen.getByText(/AUTH\$RECAPTCHA_REQUIRED/i)).toBeInTheDocument(); expect(verifyRecaptchaSpy).not.toHaveBeenCalled(); }); @@ -209,7 +223,7 @@ describe("AuthModal", () => { vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", "test-site-key"); const mockUrl = "https://github.com/login/oauth/authorize"; const mockToken = "recaptcha-token-123"; - mockGetRecaptchaResponse.mockReturnValue(mockToken); + mockExecuteAsync.mockResolvedValue(mockToken); verifyRecaptchaSpy.mockResolvedValue({ success: false }); renderWithProviders( @@ -240,7 +254,7 @@ describe("AuthModal", () => { vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", "test-site-key"); const mockUrl = "https://github.com/login/oauth/authorize"; const mockToken = "recaptcha-token-123"; - mockGetRecaptchaResponse.mockReturnValue(mockToken); + mockExecuteAsync.mockResolvedValue(mockToken); verifyRecaptchaSpy.mockResolvedValue({ success: true }); renderWithProviders( @@ -267,7 +281,7 @@ describe("AuthModal", () => { ).not.toBeInTheDocument(); }); - it("should render reCAPTCHA widget container when site key is configured", () => { + it("should render reCAPTCHA widget when site key is configured", () => { // Arrange vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", "test-site-key"); @@ -281,10 +295,7 @@ describe("AuthModal", () => { ); // Assert - expect(mockUseRecaptcha).toHaveBeenCalledWith({ - siteKey: "test-site-key", - enabled: true, - }); + expect(screen.getByTestId("recaptcha-widget")).toBeInTheDocument(); }); it("should not render reCAPTCHA widget when site key is not configured", () => { @@ -301,8 +312,7 @@ describe("AuthModal", () => { ); // Assert - const recaptchaContainer = document.querySelector("div[ref]"); - expect(recaptchaContainer).not.toBeInTheDocument(); + expect(screen.queryByTestId("recaptcha-widget")).not.toBeInTheDocument(); }); }); }); diff --git a/frontend/__tests__/hooks/use-recaptcha.test.tsx b/frontend/__tests__/hooks/use-recaptcha.test.tsx deleted file mode 100644 index e96a261166..0000000000 --- a/frontend/__tests__/hooks/use-recaptcha.test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { act, renderHook, waitFor, render } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useRecaptcha } from "#/hooks/use-recaptcha"; -import React from "react"; - -describe("useRecaptcha", () => { - let mockGrecaptcha: { - ready: (callback: () => void) => void; - render: ( - element: HTMLElement, - options: { - sitekey: string; - callback?: (token: string) => void; - "expired-callback"?: () => void; - "error-callback"?: () => void; - }, - ) => number; - getResponse: (widgetId?: number) => string; - reset: (widgetId?: number) => void; - }; - let mockScript: HTMLScriptElement; - - beforeEach(() => { - // Mock grecaptcha API - mockGrecaptcha = { - ready: vi.fn((callback: () => void) => callback()), - render: vi.fn(() => 1), - getResponse: vi.fn(() => ""), - reset: vi.fn(), - }; - - // Mock script element - create a real script element so it can be appended to DOM - const originalCreateElement = document.createElement.bind(document); - mockScript = originalCreateElement("script") as HTMLScriptElement; - - // Mock DOM methods - vi.stubGlobal("grecaptcha", undefined); - vi.spyOn(document, "createElement").mockImplementation( - (tagName: string) => { - if (tagName === "script") { - return mockScript; - } - return originalCreateElement(tagName); - }, - ); - vi.spyOn(document.head, "appendChild").mockImplementation( - vi.fn((node: Node) => node), - ); - vi.spyOn(document, "querySelector").mockReturnValue(null); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it("should return initial state when siteKey is undefined", () => { - // Arrange & Act - const { result } = renderHook(() => useRecaptcha({ siteKey: undefined })); - - // Assert - expect(result.current.recaptchaLoaded).toBe(false); - expect(result.current.recaptchaError).toBe(false); - expect(result.current.widgetId).toBe(null); - expect(result.current.recaptchaRef.current).toBe(null); - expect(document.createElement).not.toHaveBeenCalledWith("script"); - }); - - it("should not load script when enabled is false", () => { - // Arrange & Act - const { result } = renderHook(() => - useRecaptcha({ siteKey: "test-site-key", enabled: false }), - ); - - // Assert - expect(result.current.recaptchaLoaded).toBe(false); - expect(document.createElement).not.toHaveBeenCalledWith("script"); - }); - - it("should load script when siteKey is provided and grecaptcha not available", () => { - // Arrange - vi.stubGlobal("grecaptcha", undefined); - - // Act - renderHook(() => useRecaptcha({ siteKey: "test-site-key" })); - - // Assert - expect(document.createElement).toHaveBeenCalledWith("script"); - expect(mockScript.src).toBe( - "https://www.google.com/recaptcha/api.js?render=explicit", - ); - expect(mockScript.async).toBe(true); - expect(mockScript.defer).toBe(true); - expect(document.head.appendChild).toHaveBeenCalledWith(mockScript); - }); - - it("should render widget when grecaptcha is already available", async () => { - // Arrange - vi.stubGlobal("grecaptcha", mockGrecaptcha); - - const TestComponent = () => { - const { recaptchaRef, recaptchaLoaded, widgetId } = useRecaptcha({ - siteKey: "test-site-key", - }); - return
; - }; - - // Act - render(); - - // Assert - await waitFor(() => { - expect(mockGrecaptcha.render).toHaveBeenCalled(); - }); - const renderCall = vi.mocked(mockGrecaptcha.render).mock.calls[0]; - expect(renderCall[1]).toMatchObject({ - sitekey: "test-site-key", - callback: expect.any(Function), - "expired-callback": expect.any(Function), - "error-callback": expect.any(Function), - }); - }); - - it("should set error state when script fails to load", async () => { - // Arrange - vi.stubGlobal("grecaptcha", undefined); - - const { result } = renderHook(() => - useRecaptcha({ siteKey: "test-site-key" }), - ); - - // Act - simulate script error - act(() => { - if (mockScript.onerror) { - mockScript.onerror(new Event("error")); - } - }); - - // Assert - await waitFor(() => { - expect(result.current.recaptchaError).toBe(true); - }); - expect(result.current.recaptchaLoaded).toBe(false); - }); - - it("should return response token when widget is ready", async () => { - // Arrange - const mockToken = "recaptcha-token-123"; - mockGrecaptcha.getResponse = vi.fn(() => mockToken); - vi.stubGlobal("grecaptcha", mockGrecaptcha); - - const TestComponent = () => { - const { recaptchaRef, getRecaptchaResponse } = useRecaptcha({ - siteKey: "test-site-key", - }); - return ( -
-
- -
- ); - }; - - // Act - const { getByRole } = render(); - - await waitFor(() => { - expect(mockGrecaptcha.render).toHaveBeenCalled(); - }); - - act(() => { - getByRole("button").click(); - }); - - // Assert - expect((window as any).testResponse).toBe(mockToken); - expect(mockGrecaptcha.getResponse).toHaveBeenCalledWith(1); - delete (window as any).testResponse; - }); - - it("should return null when widget is not ready", () => { - // Arrange - vi.stubGlobal("grecaptcha", undefined); - - const { result } = renderHook(() => - useRecaptcha({ siteKey: "test-site-key" }), - ); - - // Act - const response = result.current.getRecaptchaResponse(); - - // Assert - expect(response).toBe(null); - }); - - it("should reset widget when resetRecaptcha is called", async () => { - // Arrange - vi.stubGlobal("grecaptcha", mockGrecaptcha); - - const TestComponent = () => { - const { recaptchaRef, resetRecaptcha } = useRecaptcha({ - siteKey: "test-site-key", - }); - return ( -
-
- -
- ); - }; - - // Act - const { getByRole } = render(); - - await waitFor(() => { - expect(mockGrecaptcha.render).toHaveBeenCalled(); - }); - - act(() => { - getByRole("button").click(); - }); - - // Assert - expect(mockGrecaptcha.reset).toHaveBeenCalledWith(1); - }); - - it("should handle widget render error gracefully", async () => { - // Arrange - mockGrecaptcha.render = vi.fn(() => { - throw new Error("Render failed"); - }); - vi.stubGlobal("grecaptcha", mockGrecaptcha); - - const TestComponent = () => { - const { recaptchaRef, recaptchaError } = useRecaptcha({ - siteKey: "test-site-key", - }); - return ( -
-
- {recaptchaError &&
Error occurred
} -
- ); - }; - - // Act - const { getByTestId } = render(); - - // Assert - await waitFor(() => { - expect(getByTestId("error")).toBeInTheDocument(); - }); - }); - - it("should cleanup script reference on unmount", () => { - // Arrange - const existingScript = document.createElement("script"); - vi.spyOn(document, "querySelector").mockReturnValue(existingScript); - - const { unmount } = renderHook(() => - useRecaptcha({ siteKey: "test-site-key" }), - ); - - // Act - unmount(); - - // Assert - expect(document.querySelector).toHaveBeenCalledWith( - 'script[src*="recaptcha/api.js"]', - ); - }); -}); diff --git a/frontend/global.d.ts b/frontend/global.d.ts index 24ef192d45..ef06cf103c 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -15,18 +15,4 @@ interface Window { company?: string; }) => void; }; - grecaptcha?: { - ready: (callback: () => void) => void; - render: ( - element: HTMLElement, - options: { - sitekey: string; - callback?: (token: string) => void; - "expired-callback"?: () => void; - "error-callback"?: () => void; - }, - ) => number; - reset: (widgetId?: number) => void; - getResponse: (widgetId?: number) => string; - }; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 33717ced21..853c8c57f1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "posthog-js": "^1.309.1", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-google-recaptcha": "^3.1.0", "react-hot-toast": "^2.6.0", "react-i18next": "^16.5.0", "react-icons": "^5.5.0", @@ -61,6 +62,7 @@ "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/react-google-recaptcha": "^2.1.9", "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", @@ -192,7 +194,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -732,7 +733,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -779,7 +779,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2331,7 +2330,6 @@ "version": "2.4.24", "resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.24.tgz", "integrity": "sha512-9GKQgUc91otQfwmq6TLE72QKxtB341aK5NpBHS3gRoWYEuNN714Zl3OXwIZNvdXPJpsTaUo1ID1ibJU9tfgwdg==", - "peer": true, "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/system-rsc": "2.3.21", @@ -2411,7 +2409,6 @@ "version": "2.4.24", "resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.24.tgz", "integrity": "sha512-lL+anmY4GGWwKyTbJ2PEBZE4talIZ3hu4yGpku9TktCVG2nC2YTwiWQFJ+Jcbf8Cf9vuLzI1sla5bz2jUqiBRA==", - "peer": true, "dependencies": { "@heroui/shared-utils": "2.1.12", "color": "^4.2.3", @@ -5127,7 +5124,6 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5588,7 +5584,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5766,7 +5761,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5782,7 +5776,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5793,11 +5786,20 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-syntax-highlighter": { "version": "15.5.13", "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", @@ -5834,7 +5836,6 @@ "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", @@ -5892,7 +5893,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -6406,8 +6406,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/accepts": { "version": "1.3.8", @@ -6435,7 +6434,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6972,7 +6970,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7661,8 +7658,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -8380,7 +8376,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8504,7 +8499,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8585,7 +8579,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8677,7 +8670,6 @@ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -8773,7 +8765,6 @@ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -8807,7 +8798,6 @@ "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9076,7 +9066,6 @@ "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9407,7 +9396,6 @@ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", "license": "MIT", - "peer": true, "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", @@ -9955,6 +9943,21 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -10075,7 +10078,6 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -10853,7 +10855,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -12555,7 +12556,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -12650,7 +12650,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", @@ -13375,7 +13374,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13437,7 +13435,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13634,11 +13631,23 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-base16-styling": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.9.1.tgz", @@ -13683,7 +13692,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13691,6 +13699,19 @@ "react": "^19.2.3" } }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-hot-toast": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", @@ -13796,7 +13817,6 @@ "version": "7.11.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", - "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -14158,7 +14178,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15154,7 +15173,6 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -15281,7 +15299,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15583,7 +15600,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15889,7 +15905,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16059,7 +16074,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16072,7 +16086,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/frontend/package.json b/frontend/package.json index f08f6ea3b6..4cf3550d1b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "posthog-js": "^1.309.1", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-google-recaptcha": "^3.1.0", "react-hot-toast": "^2.6.0", "react-i18next": "^16.5.0", "react-icons": "^5.5.0", @@ -92,6 +93,7 @@ "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/react-google-recaptcha": "^2.1.9", "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", diff --git a/frontend/src/components/features/waitlist/auth-modal.tsx b/frontend/src/components/features/waitlist/auth-modal.tsx index e6f6cfff58..44b714f546 100644 --- a/frontend/src/components/features/waitlist/auth-modal.tsx +++ b/frontend/src/components/features/waitlist/auth-modal.tsx @@ -1,5 +1,6 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import { useTranslation } from "react-i18next"; +import ReCAPTCHA from "react-google-recaptcha"; import { I18nKey } from "#/i18n/declaration"; import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; @@ -13,7 +14,6 @@ import { useAuthUrl } from "#/hooks/use-auth-url"; import { GetConfigResponse } from "#/api/option-service/option.types"; import { Provider } from "#/types/settings"; import { useTracking } from "#/hooks/use-tracking"; -import { useRecaptcha } from "#/hooks/use-recaptcha"; import { useVerifyRecaptcha } from "#/hooks/mutation/use-verify-recaptcha"; interface AuthModalProps { @@ -32,20 +32,11 @@ export function AuthModal({ const { t } = useTranslation(); const { trackLoginButtonClick } = useTracking(); const [recaptchaError, setRecaptchaError] = useState(false); + const recaptchaRef = useRef(null); // Get reCAPTCHA site key from environment variable const recaptchaSiteKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY || undefined; - // Initialize reCAPTCHA - const { - recaptchaRef, - getRecaptchaResponse, - recaptchaError: recaptchaLoadError, - } = useRecaptcha({ - siteKey: recaptchaSiteKey, - enabled: !!recaptchaSiteKey, - }); - // Hook for verifying reCAPTCHA with backend const { mutateAsync: verifyRecaptcha } = useVerifyRecaptcha(); @@ -80,15 +71,22 @@ export function AuthModal({ return true; } - const response = getRecaptchaResponse(); - if (!response) { + if (!recaptchaRef.current) { setRecaptchaError(true); return false; } + // For invisible reCAPTCHA, execute the challenge first try { + const token = await recaptchaRef.current.executeAsync(); + if (!token) { + setRecaptchaError(true); + return false; + } + // Verify the token with the backend using the mutation hook - const verificationResult = await verifyRecaptcha(response); + const verificationResult = await verifyRecaptcha(token); + console.log("verificationResult", verificationResult); if (!verificationResult.success) { setRecaptchaError(true); return false; @@ -277,12 +275,14 @@ export function AuthModal({ {recaptchaSiteKey && (
-
- {recaptchaLoadError && ( -
- {t(I18nKey.AUTH$RECAPTCHA_LOAD_ERROR)} -
- )} + { + setRecaptchaError(true); + }} + />
)} diff --git a/frontend/src/hooks/use-recaptcha.ts b/frontend/src/hooks/use-recaptcha.ts deleted file mode 100644 index 475d6df945..0000000000 --- a/frontend/src/hooks/use-recaptcha.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -interface UseRecaptchaOptions { - siteKey: string | undefined; - enabled?: boolean; -} - -interface UseRecaptchaReturn { - recaptchaLoaded: boolean; - recaptchaError: boolean; - widgetId: number | null; - recaptchaRef: React.RefObject; - getRecaptchaResponse: () => string | null; - resetRecaptcha: () => void; -} - -/** - * Hook to load and manage Google reCAPTCHA v2 - * @param siteKey - The reCAPTCHA site key - * @param enabled - Whether to load reCAPTCHA (default: true) - * @returns Object with reCAPTCHA state and methods - */ -export function useRecaptcha({ - siteKey, - enabled = true, -}: UseRecaptchaOptions): UseRecaptchaReturn { - const [recaptchaLoaded, setRecaptchaLoaded] = useState(false); - const [recaptchaError, setRecaptchaError] = useState(false); - const [widgetId, setWidgetId] = useState(null); - const recaptchaRef = useRef(null); - const scriptLoadedRef = useRef(false); - - useEffect(() => { - if (!enabled || !siteKey || scriptLoadedRef.current) { - return undefined; - } - - // Check if script is already loaded - if (window.grecaptcha) { - setRecaptchaLoaded(true); - scriptLoadedRef.current = true; - return undefined; - } - - // Load the reCAPTCHA script - const script = document.createElement("script"); - script.src = "https://www.google.com/recaptcha/api.js?render=explicit"; - script.async = true; - script.defer = true; - - script.onload = () => { - if (window.grecaptcha) { - window.grecaptcha.ready(() => { - setRecaptchaLoaded(true); - scriptLoadedRef.current = true; - }); - } - }; - - script.onerror = () => { - setRecaptchaError(true); - }; - - document.head.appendChild(script); - - return () => { - // Cleanup: remove script if component unmounts - const existingScript = document.querySelector( - 'script[src*="recaptcha/api.js"]', - ); - if (existingScript) { - // Don't remove script as it might be used elsewhere - // Just reset the state - setRecaptchaLoaded(false); - scriptLoadedRef.current = false; - } - }; - }, [siteKey, enabled]); - - // Render the reCAPTCHA widget when script is loaded - useEffect(() => { - if ( - !recaptchaLoaded || - !siteKey || - !recaptchaRef.current || - widgetId !== null - ) { - return; - } - - if (window.grecaptcha && recaptchaRef.current) { - try { - const id = window.grecaptcha.render(recaptchaRef.current, { - sitekey: siteKey, - callback: () => { - // CAPTCHA completed successfully - }, - "expired-callback": () => { - // CAPTCHA expired - }, - "error-callback": () => { - // CAPTCHA error - }, - }); - setWidgetId(id); - } catch (error) { - setRecaptchaError(true); - } - } - }, [recaptchaLoaded, siteKey, widgetId]); - - const getRecaptchaResponse = (): string | null => { - if (!window.grecaptcha || widgetId === null) { - return null; - } - const response = window.grecaptcha.getResponse(widgetId); - return response || null; - }; - - const resetRecaptcha = () => { - if (window.grecaptcha && widgetId !== null) { - window.grecaptcha.reset(widgetId); - } - }; - - return { - recaptchaLoaded, - recaptchaError, - widgetId, - recaptchaRef, - getRecaptchaResponse, - resetRecaptcha, - }; -}