mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat: add captcha to auth modal
This commit is contained in:
parent
5553d3ca2e
commit
b49b309885
@ -43,3 +43,4 @@ BLOCKED_EMAIL_DOMAINS = [
|
||||
for domain in os.getenv('BLOCKED_EMAIL_DOMAINS', '').split(',')
|
||||
if domain.strip()
|
||||
]
|
||||
RECAPTCHA_SECRET_KEY = os.getenv('RECAPTCHA_SECRET_KEY', '').strip()
|
||||
|
||||
@ -159,6 +159,7 @@ class SetAuthCookieMiddleware:
|
||||
'/api/billing/cancel',
|
||||
'/api/billing/customer-setup-success',
|
||||
'/api/billing/stripe-webhook',
|
||||
'/api/verify-recaptcha',
|
||||
'/oauth/device/authorize',
|
||||
'/oauth/device/token',
|
||||
)
|
||||
|
||||
@ -3,15 +3,17 @@ from datetime import datetime, timezone
|
||||
from typing import Annotated, Literal, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
import posthog
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, Response, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import SecretStr
|
||||
from pydantic import BaseModel, SecretStr
|
||||
from server.auth.auth_utils import user_verifier
|
||||
from server.auth.constants import (
|
||||
KEYCLOAK_CLIENT_ID,
|
||||
KEYCLOAK_REALM_NAME,
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
RECAPTCHA_SECRET_KEY,
|
||||
ROLE_CHECK_ENABLED,
|
||||
)
|
||||
from server.auth.domain_blocker import domain_blocker
|
||||
@ -32,6 +34,7 @@ from openhands.server.services.conversation_service import create_provider_token
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import get_access_token
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
@ -457,3 +460,72 @@ async def refresh_tokens(
|
||||
)
|
||||
|
||||
return TokenResponse(token=token.get_secret_value())
|
||||
|
||||
|
||||
class RecaptchaVerifyRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@api_router.post('/verify-recaptcha')
|
||||
async def verify_recaptcha(request_data: RecaptchaVerifyRequest):
|
||||
"""
|
||||
Verify a reCAPTCHA token with Google's reCAPTCHA API.
|
||||
This endpoint is public and does not require authentication.
|
||||
"""
|
||||
if not RECAPTCHA_SECRET_KEY:
|
||||
logger.warning('reCAPTCHA secret key not configured')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
content={'success': False, 'error': 'reCAPTCHA not configured'},
|
||||
)
|
||||
|
||||
if not request_data.token:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'success': False, 'error': 'Token is required'},
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(
|
||||
'https://www.google.com/recaptcha/api/siteverify',
|
||||
data={
|
||||
'secret': RECAPTCHA_SECRET_KEY,
|
||||
'response': request_data.token,
|
||||
},
|
||||
timeout=10.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get('success', False):
|
||||
logger.debug('reCAPTCHA verification successful')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'success': True},
|
||||
)
|
||||
else:
|
||||
error_codes = result.get('error-codes', [])
|
||||
logger.warning(
|
||||
f'reCAPTCHA verification failed: {error_codes}',
|
||||
extra={'error_codes': error_codes},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
'success': False,
|
||||
'error_codes': error_codes,
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f'Error calling reCAPTCHA API: {str(e)}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
content={'success': False, 'error': 'Failed to verify reCAPTCHA'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Unexpected error during reCAPTCHA verification: {str(e)}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'success': False, 'error': 'Internal server error'},
|
||||
)
|
||||
|
||||
@ -8,11 +8,13 @@ from pydantic import SecretStr
|
||||
from server.auth.auth_error import AuthError
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.routes.auth import (
|
||||
RecaptchaVerifyRequest,
|
||||
authenticate,
|
||||
keycloak_callback,
|
||||
keycloak_offline_callback,
|
||||
logout,
|
||||
set_response_cookie,
|
||||
verify_recaptcha,
|
||||
)
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
@ -635,3 +637,155 @@ async def test_keycloak_callback_missing_email(mock_request):
|
||||
assert isinstance(result, RedirectResponse)
|
||||
mock_domain_blocker.is_domain_blocked.assert_not_called()
|
||||
mock_token_manager.disable_keycloak_user.assert_not_called()
|
||||
|
||||
|
||||
class TestVerifyRecaptcha:
|
||||
"""Test reCAPTCHA verification endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_recaptcha_success(self):
|
||||
"""Test successful reCAPTCHA verification with valid token."""
|
||||
# Arrange
|
||||
request_data = RecaptchaVerifyRequest(token='valid_token_12345')
|
||||
mock_google_response = MagicMock()
|
||||
mock_google_response.json.return_value = {'success': True}
|
||||
mock_google_response.raise_for_status = MagicMock()
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'),
|
||||
patch('httpx.AsyncClient') as mock_client,
|
||||
):
|
||||
mock_client_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_client_instance
|
||||
mock_client_instance.post = AsyncMock(return_value=mock_google_response)
|
||||
|
||||
# Act
|
||||
result = await verify_recaptcha(request_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_200_OK
|
||||
result_data = result.body.decode()
|
||||
assert '"success":true' in result_data
|
||||
mock_client_instance.post.assert_called_once_with(
|
||||
'https://www.google.com/recaptcha/api/siteverify',
|
||||
data={'secret': 'test_secret_key', 'response': 'valid_token_12345'},
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_recaptcha_invalid_token(self):
|
||||
"""Test reCAPTCHA verification with invalid token."""
|
||||
# Arrange
|
||||
request_data = RecaptchaVerifyRequest(token='invalid_token_12345')
|
||||
mock_google_response = MagicMock()
|
||||
mock_google_response.json.return_value = {
|
||||
'success': False,
|
||||
'error-codes': ['invalid-input-response'],
|
||||
}
|
||||
mock_google_response.raise_for_status = MagicMock()
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'),
|
||||
patch('httpx.AsyncClient') as mock_client,
|
||||
):
|
||||
mock_client_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_client_instance
|
||||
mock_client_instance.post = AsyncMock(return_value=mock_google_response)
|
||||
|
||||
# Act
|
||||
result = await verify_recaptcha(request_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_200_OK
|
||||
result_data = result.body.decode()
|
||||
assert '"success":false' in result_data
|
||||
assert '"error_codes"' in result_data
|
||||
assert 'invalid-input-response' in result_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_recaptcha_missing_token(self):
|
||||
"""Test reCAPTCHA verification with empty token."""
|
||||
# Arrange
|
||||
request_data = RecaptchaVerifyRequest(token='')
|
||||
|
||||
with patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'):
|
||||
# Act
|
||||
result = await verify_recaptcha(request_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_400_BAD_REQUEST
|
||||
result_data = result.body.decode()
|
||||
assert '"success":false' in result_data
|
||||
assert 'Token is required' in result_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_recaptcha_secret_key_not_configured(self):
|
||||
"""Test reCAPTCHA verification when secret key is not configured."""
|
||||
# Arrange
|
||||
request_data = RecaptchaVerifyRequest(token='valid_token_12345')
|
||||
|
||||
with patch('server.routes.auth.RECAPTCHA_SECRET_KEY', ''):
|
||||
# Act
|
||||
result = await verify_recaptcha(request_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
result_data = result.body.decode()
|
||||
assert '"success":false' in result_data
|
||||
assert 'reCAPTCHA not configured' in result_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_recaptcha_http_error(self):
|
||||
"""Test reCAPTCHA verification when Google API returns HTTP error."""
|
||||
# Arrange
|
||||
import httpx
|
||||
|
||||
request_data = RecaptchaVerifyRequest(token='valid_token_12345')
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'),
|
||||
patch('httpx.AsyncClient') as mock_client,
|
||||
):
|
||||
mock_client_instance = AsyncMock()
|
||||
mock_client.return_value.__aenter__.return_value = mock_client_instance
|
||||
mock_client_instance.post = AsyncMock(
|
||||
side_effect=httpx.HTTPError('Network error')
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await verify_recaptcha(request_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
result_data = result.body.decode()
|
||||
assert '"success":false' in result_data
|
||||
assert 'Failed to verify reCAPTCHA' in result_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_recaptcha_unexpected_exception(self):
|
||||
"""Test reCAPTCHA verification when unexpected exception occurs."""
|
||||
# Arrange
|
||||
request_data = RecaptchaVerifyRequest(token='valid_token_12345')
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.RECAPTCHA_SECRET_KEY', 'test_secret_key'),
|
||||
patch('httpx.AsyncClient') as mock_client,
|
||||
):
|
||||
mock_client.return_value.__aenter__.side_effect = ValueError(
|
||||
'Unexpected error'
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await verify_recaptcha(request_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
result_data = result.body.decode()
|
||||
assert '"success":false' in result_data
|
||||
assert 'Internal server error' in result_data
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AuthModal } from "#/components/features/waitlist/auth-modal";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
|
||||
// Mock the useAuthUrl hook
|
||||
vi.mock("#/hooks/use-auth-url", () => ({
|
||||
@ -15,9 +17,30 @@ 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(),
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("#/hooks/use-recaptcha", () => ({
|
||||
useRecaptcha: mockUseRecaptcha,
|
||||
}));
|
||||
|
||||
describe("AuthModal", () => {
|
||||
let verifyRecaptchaSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
verifyRecaptchaSpy = vi.spyOn(AuthService, "verifyRecaptcha");
|
||||
mockGetRecaptchaResponse.mockReturnValue("");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -26,7 +49,7 @@ describe("AuthModal", () => {
|
||||
});
|
||||
|
||||
it("should render the GitHub and GitLab buttons", () => {
|
||||
render(
|
||||
renderWithProviders(
|
||||
<AuthModal
|
||||
githubAuthUrl="mock-url"
|
||||
appMode="saas"
|
||||
@ -46,9 +69,11 @@ describe("AuthModal", () => {
|
||||
});
|
||||
|
||||
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", undefined);
|
||||
const mockUrl = "https://github.com/login/oauth/authorize";
|
||||
render(
|
||||
renderWithProviders(
|
||||
<AuthModal
|
||||
githubAuthUrl={mockUrl}
|
||||
appMode="saas"
|
||||
@ -56,16 +81,18 @@ describe("AuthModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
});
|
||||
await user.click(githubButton);
|
||||
|
||||
// Assert
|
||||
expect(window.location.href).toBe(mockUrl);
|
||||
});
|
||||
|
||||
it("should render Terms of Service and Privacy Policy text with correct links", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
renderWithProviders(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
|
||||
// Find the terms of service section using data-testid
|
||||
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
|
||||
@ -106,7 +133,7 @@ describe("AuthModal", () => {
|
||||
});
|
||||
|
||||
it("should open Terms of Service link in new tab", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
renderWithProviders(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
|
||||
const tosLink = screen.getByRole("link", {
|
||||
name: "COMMON$TERMS_OF_SERVICE",
|
||||
@ -115,11 +142,167 @@ describe("AuthModal", () => {
|
||||
});
|
||||
|
||||
it("should open Privacy Policy link in new tab", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
renderWithProviders(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
|
||||
const privacyLink = screen.getByRole("link", {
|
||||
name: "COMMON$PRIVACY_POLICY",
|
||||
});
|
||||
expect(privacyLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
describe("reCAPTCHA integration", () => {
|
||||
it("should allow auth when reCAPTCHA is not configured", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", undefined);
|
||||
const mockUrl = "https://github.com/login/oauth/authorize";
|
||||
|
||||
renderWithProviders(
|
||||
<AuthModal
|
||||
githubAuthUrl={mockUrl}
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
});
|
||||
await user.click(githubButton);
|
||||
|
||||
// Assert
|
||||
expect(window.location.href).toBe(mockUrl);
|
||||
expect(verifyRecaptchaSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should block auth and show error when reCAPTCHA is not completed", 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("");
|
||||
|
||||
renderWithProviders(
|
||||
<AuthModal
|
||||
githubAuthUrl={mockUrl}
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
});
|
||||
await user.click(githubButton);
|
||||
|
||||
// Assert
|
||||
expect(window.location.href).toBe("");
|
||||
expect(screen.getByText(/AUTH\$RECAPTCHA_REQUIRED/i)).toBeInTheDocument();
|
||||
expect(verifyRecaptchaSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should block auth when reCAPTCHA verification fails", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
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);
|
||||
verifyRecaptchaSpy.mockResolvedValue({ success: false });
|
||||
|
||||
renderWithProviders(
|
||||
<AuthModal
|
||||
githubAuthUrl={mockUrl}
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
});
|
||||
await user.click(githubButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("");
|
||||
});
|
||||
expect(screen.getByText(/AUTH\$RECAPTCHA_REQUIRED/i)).toBeInTheDocument();
|
||||
expect(verifyRecaptchaSpy).toHaveBeenCalledWith(mockToken);
|
||||
});
|
||||
|
||||
it("should allow auth when reCAPTCHA verification succeeds", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
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);
|
||||
verifyRecaptchaSpy.mockResolvedValue({ success: true });
|
||||
|
||||
renderWithProviders(
|
||||
<AuthModal
|
||||
githubAuthUrl={mockUrl}
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
});
|
||||
await user.click(githubButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(mockUrl);
|
||||
});
|
||||
expect(verifyRecaptchaSpy).toHaveBeenCalledWith(mockToken);
|
||||
expect(
|
||||
screen.queryByText(/AUTH\$RECAPTCHA_REQUIRED/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render reCAPTCHA widget container when site key is configured", () => {
|
||||
// Arrange
|
||||
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", "test-site-key");
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<AuthModal
|
||||
githubAuthUrl="mock-url"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(mockUseRecaptcha).toHaveBeenCalledWith({
|
||||
siteKey: "test-site-key",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render reCAPTCHA widget when site key is not configured", () => {
|
||||
// Arrange
|
||||
vi.stubEnv("VITE_RECAPTCHA_SITE_KEY", undefined);
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<AuthModal
|
||||
githubAuthUrl="mock-url"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const recaptchaContainer = document.querySelector("div[ref]");
|
||||
expect(recaptchaContainer).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
280
frontend/__tests__/hooks/use-recaptcha.test.tsx
Normal file
280
frontend/__tests__/hooks/use-recaptcha.test.tsx
Normal file
@ -0,0 +1,280 @@
|
||||
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 <div ref={recaptchaRef} data-testid="recaptcha-container" />;
|
||||
};
|
||||
|
||||
// Act
|
||||
render(<TestComponent />);
|
||||
|
||||
// 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 (
|
||||
<div>
|
||||
<div ref={recaptchaRef} data-testid="recaptcha-container" />
|
||||
<button
|
||||
onClick={() => {
|
||||
const response = getRecaptchaResponse();
|
||||
// Store response for testing
|
||||
(window as any).testResponse = response;
|
||||
}}
|
||||
>
|
||||
Get Response
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Act
|
||||
const { getByRole } = render(<TestComponent />);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div ref={recaptchaRef} data-testid="recaptcha-container" />
|
||||
<button onClick={resetRecaptcha}>Reset</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Act
|
||||
const { getByRole } = render(<TestComponent />);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div ref={recaptchaRef} data-testid="recaptcha-container" />
|
||||
{recaptchaError && <div data-testid="error">Error occurred</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Act
|
||||
const { getByTestId } = render(<TestComponent />);
|
||||
|
||||
// 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"]',
|
||||
);
|
||||
});
|
||||
});
|
||||
16
frontend/global.d.ts
vendored
16
frontend/global.d.ts
vendored
@ -5,7 +5,7 @@ interface Window {
|
||||
init: (config: { clientID: string }) => void;
|
||||
identify: (identity: {
|
||||
username: string;
|
||||
type: "github" |"email";
|
||||
type: "github" | "email";
|
||||
other_identities?: Array<{
|
||||
username: string;
|
||||
type: "github" | "email";
|
||||
@ -15,4 +15,18 @@ 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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -47,6 +47,26 @@ class AuthService {
|
||||
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
|
||||
await openHands.post(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify reCAPTCHA token with the backend
|
||||
* @param token The reCAPTCHA token to verify
|
||||
* @returns Response indicating if verification was successful
|
||||
*/
|
||||
static async verifyRecaptcha(token: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
error_codes?: string[];
|
||||
}> {
|
||||
const { data } = await openHands.post<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
error_codes?: string[];
|
||||
}>("/api/verify-recaptcha", {
|
||||
token,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthService;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
@ -13,6 +13,8 @@ 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 {
|
||||
githubAuthUrl: string | null;
|
||||
@ -29,6 +31,23 @@ export function AuthModal({
|
||||
}: AuthModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackLoginButtonClick } = useTracking();
|
||||
const [recaptchaError, setRecaptchaError] = useState(false);
|
||||
|
||||
// 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();
|
||||
|
||||
const gitlabAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
@ -54,7 +73,43 @@ export function AuthModal({
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
// Validate reCAPTCHA before proceeding with auth
|
||||
const validateRecaptcha = async (): Promise<boolean> => {
|
||||
if (!recaptchaSiteKey) {
|
||||
// If reCAPTCHA is not configured, allow auth to proceed
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = getRecaptchaResponse();
|
||||
if (!response) {
|
||||
setRecaptchaError(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the token with the backend using the mutation hook
|
||||
const verificationResult = await verifyRecaptcha(response);
|
||||
if (!verificationResult.success) {
|
||||
setRecaptchaError(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
setRecaptchaError(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Log error to console for debugging
|
||||
console.error("reCAPTCHA verification failed", error);
|
||||
setRecaptchaError(true);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubAuth = async () => {
|
||||
const hasCaptchaVerified = await validateRecaptcha();
|
||||
if (!hasCaptchaVerified) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (githubAuthUrl) {
|
||||
trackLoginButtonClick({ provider: "github" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
@ -62,7 +117,12 @@ export function AuthModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitLabAuth = () => {
|
||||
const handleGitLabAuth = async () => {
|
||||
const hasCaptchaVerified = await validateRecaptcha();
|
||||
if (!hasCaptchaVerified) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gitlabAuthUrl) {
|
||||
trackLoginButtonClick({ provider: "gitlab" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
@ -70,7 +130,12 @@ export function AuthModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBitbucketAuth = () => {
|
||||
const handleBitbucketAuth = async () => {
|
||||
const hasCaptchaVerified = await validateRecaptcha();
|
||||
if (!hasCaptchaVerified) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bitbucketAuthUrl) {
|
||||
trackLoginButtonClick({ provider: "bitbucket" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
@ -78,14 +143,24 @@ export function AuthModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAzureDevOpsAuth = () => {
|
||||
const handleAzureDevOpsAuth = async () => {
|
||||
const hasCaptchaVerified = await validateRecaptcha();
|
||||
if (!hasCaptchaVerified) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (azureDevOpsAuthUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = azureDevOpsAuthUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterpriseSsoAuth = () => {
|
||||
const handleEnterpriseSsoAuth = async () => {
|
||||
const hasCaptchaVerified = await validateRecaptcha();
|
||||
if (!hasCaptchaVerified) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enterpriseSsoUrl) {
|
||||
trackLoginButtonClick({ provider: "enterprise_sso" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
@ -123,6 +198,11 @@ export function AuthModal({
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<OpenHandsLogo width={68} height={46} />
|
||||
{recaptchaError && (
|
||||
<div className="text-sm text-red-500 text-center mt-2 mb-2">
|
||||
{t(I18nKey.AUTH$RECAPTCHA_REQUIRED)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER)}
|
||||
@ -194,6 +274,17 @@ export function AuthModal({
|
||||
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
|
||||
</BrandButton>
|
||||
)}
|
||||
|
||||
{recaptchaSiteKey && (
|
||||
<div className="flex justify-center mt-2">
|
||||
<div ref={recaptchaRef} />
|
||||
{recaptchaLoadError && (
|
||||
<div className="text-xs text-muted-foreground text-center mt-1">
|
||||
{t(I18nKey.AUTH$RECAPTCHA_LOAD_ERROR)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
7
frontend/src/hooks/mutation/use-verify-recaptcha.ts
Normal file
7
frontend/src/hooks/mutation/use-verify-recaptcha.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
|
||||
export const useVerifyRecaptcha = () =>
|
||||
useMutation({
|
||||
mutationFn: (token: string) => AuthService.verifyRecaptcha(token),
|
||||
});
|
||||
134
frontend/src/hooks/use-recaptcha.ts
Normal file
134
frontend/src/hooks/use-recaptcha.ts
Normal file
@ -0,0 +1,134 @@
|
||||
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<HTMLDivElement | null>;
|
||||
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<number | null>(null);
|
||||
const recaptchaRef = useRef<HTMLDivElement>(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,
|
||||
};
|
||||
}
|
||||
@ -730,6 +730,8 @@ export enum I18nKey {
|
||||
MICROAGENT_MANAGEMENT$USE_MICROAGENTS = "MICROAGENT_MANAGEMENT$USE_MICROAGENTS",
|
||||
AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR = "AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
|
||||
AUTH$NO_PROVIDERS_CONFIGURED = "AUTH$NO_PROVIDERS_CONFIGURED",
|
||||
AUTH$RECAPTCHA_REQUIRED = "AUTH$RECAPTCHA_REQUIRED",
|
||||
AUTH$RECAPTCHA_LOAD_ERROR = "AUTH$RECAPTCHA_LOAD_ERROR",
|
||||
COMMON$TERMS_OF_SERVICE = "COMMON$TERMS_OF_SERVICE",
|
||||
COMMON$AND = "COMMON$AND",
|
||||
COMMON$PRIVACY_POLICY = "COMMON$PRIVACY_POLICY",
|
||||
|
||||
@ -11679,6 +11679,38 @@
|
||||
"de": "Mindestens ein Identitätsanbieter muss konfiguriert werden (z.B. GitHub)",
|
||||
"uk": "Принаймні один постачальник ідентифікації має бути налаштований (наприклад, GitHub)"
|
||||
},
|
||||
"AUTH$RECAPTCHA_REQUIRED": {
|
||||
"en": "Please complete the reCAPTCHA verification to continue",
|
||||
"ja": "続行するには、reCAPTCHAの確認を完了してください",
|
||||
"zh-CN": "请完成 reCAPTCHA 验证以继续",
|
||||
"zh-TW": "請完成 reCAPTCHA 驗證以繼續",
|
||||
"ko-KR": "계속하려면 reCAPTCHA 확인을 완료하세요",
|
||||
"no": "Vennligst fullfør reCAPTCHA-verifiseringen for å fortsette",
|
||||
"it": "Completa la verifica reCAPTCHA per continuare",
|
||||
"pt": "Por favor, complete a verificação reCAPTCHA para continuar",
|
||||
"es": "Por favor, complete la verificación reCAPTCHA para continuar",
|
||||
"ar": "يرجى إكمال التحقق من reCAPTCHA للمتابعة",
|
||||
"fr": "Veuillez compléter la vérification reCAPTCHA pour continuer",
|
||||
"tr": "Devam etmek için lütfen reCAPTCHA doğrulamasını tamamlayın",
|
||||
"de": "Bitte vervollständigen Sie die reCAPTCHA-Überprüfung, um fortzufahren",
|
||||
"uk": "Будь ласка, завершіть перевірку reCAPTCHA, щоб продовжити"
|
||||
},
|
||||
"AUTH$RECAPTCHA_LOAD_ERROR": {
|
||||
"en": "Failed to load reCAPTCHA",
|
||||
"ja": "reCAPTCHAの読み込みに失敗しました",
|
||||
"zh-CN": "加载 reCAPTCHA 失败",
|
||||
"zh-TW": "載入 reCAPTCHA 失敗",
|
||||
"ko-KR": "reCAPTCHA 로드 실패",
|
||||
"no": "Kunne ikke laste reCAPTCHA",
|
||||
"it": "Impossibile caricare reCAPTCHA",
|
||||
"pt": "Falha ao carregar reCAPTCHA",
|
||||
"es": "Error al cargar reCAPTCHA",
|
||||
"ar": "فشل تحميل reCAPTCHA",
|
||||
"fr": "Échec du chargement de reCAPTCHA",
|
||||
"tr": "reCAPTCHA yüklenemedi",
|
||||
"de": "reCAPTCHA konnte nicht geladen werden",
|
||||
"uk": "Не вдалося завантажити reCAPTCHA"
|
||||
},
|
||||
"COMMON$TERMS_OF_SERVICE": {
|
||||
"en": "Terms of Service",
|
||||
"ja": "利用規約",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user