From e2b2aa52cdc091426a30fe62b44e2ff9a4a2cb16 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:56:02 +0700 Subject: [PATCH] feat: require email verification for new signups (#12123) --- enterprise/server/routes/auth.py | 12 + enterprise/server/routes/email.py | 16 +- .../unit/server/routes/test_email_routes.py | 151 +++++++++++ enterprise/tests/unit/test_auth_routes.py | 85 ++++++ .../components/features/auth-modal.test.tsx | 39 ++- .../email-verification-modal.test.tsx | 28 ++ .../shared/terms-and-privacy-notice.test.tsx | 48 ++++ .../__tests__/routes/root-layout.test.tsx | 242 ++++++++++++++++++ .../features/waitlist/auth-modal.tsx | 40 +-- .../waitlist/email-verification-modal.tsx | 31 +++ .../shared/terms-and-privacy-notice.tsx | 37 +++ frontend/src/hooks/use-email-verification.ts | 63 +++++ frontend/src/i18n/declaration.ts | 2 + frontend/src/i18n/translation.json | 32 +++ frontend/src/routes/root-layout.tsx | 17 ++ 15 files changed, 810 insertions(+), 33 deletions(-) create mode 100644 enterprise/tests/unit/server/routes/test_email_routes.py create mode 100644 frontend/__tests__/components/features/waitlist/email-verification-modal.test.tsx create mode 100644 frontend/__tests__/components/shared/terms-and-privacy-notice.test.tsx create mode 100644 frontend/__tests__/routes/root-layout.test.tsx create mode 100644 frontend/src/components/features/waitlist/email-verification-modal.tsx create mode 100644 frontend/src/components/shared/terms-and-privacy-notice.tsx create mode 100644 frontend/src/hooks/use-email-verification.ts diff --git a/enterprise/server/routes/auth.py b/enterprise/server/routes/auth.py index 3ea384b403..e911538da6 100644 --- a/enterprise/server/routes/auth.py +++ b/enterprise/server/routes/auth.py @@ -202,6 +202,18 @@ async def keycloak_callback( extra={'user_id': user_id, 'email': email}, ) + # Check email verification status + email_verified = user_info.get('email_verified', False) + if not email_verified: + # Send verification email + # Import locally to avoid circular import with email.py + from server.routes.email import verify_email + + await verify_email(request=request, user_id=user_id, is_auth_flow=True) + redirect_url = f'{request.base_url}?email_verification_required=true' + response = RedirectResponse(redirect_url, status_code=302) + return response + # default to github IDP for now. # TODO: remove default once Keycloak is updated universally with the new attribute. idp: str = user_info.get('identity_provider', ProviderType.GITHUB.value) diff --git a/enterprise/server/routes/email.py b/enterprise/server/routes/email.py index b0d88afaa0..b58adf9a4f 100644 --- a/enterprise/server/routes/email.py +++ b/enterprise/server/routes/email.py @@ -74,7 +74,7 @@ async def update_email( accepted_tos=user_auth.accepted_tos, ) - await _verify_email(request=request, user_id=user_id) + await verify_email(request=request, user_id=user_id) logger.info(f'Updating email address for {user_id} to {email}') return response @@ -91,8 +91,10 @@ async def update_email( @api_router.put('/verify') -async def verify_email(request: Request, user_id: str = Depends(get_user_id)): - await _verify_email(request=request, user_id=user_id) +async def resend_email_verification( + request: Request, user_id: str = Depends(get_user_id) +): + await verify_email(request=request, user_id=user_id) logger.info(f'Resending verification email for {user_id}') return JSONResponse( @@ -124,10 +126,14 @@ async def verified_email(request: Request): return response -async def _verify_email(request: Request, user_id: str): +async def verify_email(request: Request, user_id: str, is_auth_flow: bool = False): keycloak_admin = get_keycloak_admin() scheme = 'http' if request.url.hostname == 'localhost' else 'https' - redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified' + redirect_uri = ( + f'{scheme}://{request.url.netloc}?email_verified=true' + if is_auth_flow + else f'{scheme}://{request.url.netloc}/api/email/verified' + ) logger.info(f'Redirect URI: {redirect_uri}') await keycloak_admin.a_send_verify_email( user_id=user_id, diff --git a/enterprise/tests/unit/server/routes/test_email_routes.py b/enterprise/tests/unit/server/routes/test_email_routes.py new file mode 100644 index 0000000000..8f5ba12e87 --- /dev/null +++ b/enterprise/tests/unit/server/routes/test_email_routes.py @@ -0,0 +1,151 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import Request +from fastapi.responses import RedirectResponse +from pydantic import SecretStr +from server.auth.saas_user_auth import SaasUserAuth +from server.routes.email import verified_email, verify_email + + +@pytest.fixture +def mock_request(): + """Create a mock request object.""" + request = MagicMock(spec=Request) + request.url = MagicMock() + request.url.hostname = 'localhost' + request.url.netloc = 'localhost:8000' + request.url.path = '/api/email/verified' + request.base_url = 'http://localhost:8000/' + request.headers = {} + request.cookies = {} + request.query_params = MagicMock() + return request + + +@pytest.fixture +def mock_user_auth(): + """Create a mock SaasUserAuth object.""" + auth = MagicMock(spec=SaasUserAuth) + auth.access_token = SecretStr('test_access_token') + auth.refresh_token = SecretStr('test_refresh_token') + auth.email = 'test@example.com' + auth.email_verified = False + auth.accepted_tos = True + auth.refresh = AsyncMock() + return auth + + +@pytest.mark.asyncio +async def test_verify_email_default_behavior(mock_request): + """Test verify_email with default is_auth_flow=False.""" + # Arrange + user_id = 'test_user_id' + mock_keycloak_admin = AsyncMock() + mock_keycloak_admin.a_send_verify_email = AsyncMock() + + # Act + with patch( + 'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin + ): + await verify_email(request=mock_request, user_id=user_id) + + # Assert + mock_keycloak_admin.a_send_verify_email.assert_called_once() + call_args = mock_keycloak_admin.a_send_verify_email.call_args + assert call_args.kwargs['user_id'] == user_id + assert ( + call_args.kwargs['redirect_uri'] == 'http://localhost:8000/api/email/verified' + ) + assert 'client_id' in call_args.kwargs + + +@pytest.mark.asyncio +async def test_verify_email_with_auth_flow(mock_request): + """Test verify_email with is_auth_flow=True.""" + # Arrange + user_id = 'test_user_id' + mock_keycloak_admin = AsyncMock() + mock_keycloak_admin.a_send_verify_email = AsyncMock() + + # Act + with patch( + 'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin + ): + await verify_email(request=mock_request, user_id=user_id, is_auth_flow=True) + + # Assert + mock_keycloak_admin.a_send_verify_email.assert_called_once() + call_args = mock_keycloak_admin.a_send_verify_email.call_args + assert call_args.kwargs['user_id'] == user_id + assert ( + call_args.kwargs['redirect_uri'] == 'http://localhost:8000?email_verified=true' + ) + assert 'client_id' in call_args.kwargs + + +@pytest.mark.asyncio +async def test_verify_email_https_scheme(mock_request): + """Test verify_email uses https scheme for non-localhost hosts.""" + # Arrange + user_id = 'test_user_id' + mock_request.url.hostname = 'example.com' + mock_request.url.netloc = 'example.com' + mock_keycloak_admin = AsyncMock() + mock_keycloak_admin.a_send_verify_email = AsyncMock() + + # Act + with patch( + 'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin + ): + await verify_email(request=mock_request, user_id=user_id, is_auth_flow=True) + + # Assert + call_args = mock_keycloak_admin.a_send_verify_email.call_args + assert call_args.kwargs['redirect_uri'].startswith('https://') + + +@pytest.mark.asyncio +async def test_verified_email_default_redirect(mock_request, mock_user_auth): + """Test verified_email redirects to /settings/user by default.""" + # Arrange + mock_request.query_params.get.return_value = None + + # Act + with ( + patch('server.routes.email.get_user_auth', return_value=mock_user_auth), + patch('server.routes.email.set_response_cookie') as mock_set_cookie, + ): + result = await verified_email(mock_request) + + # Assert + assert isinstance(result, RedirectResponse) + assert result.status_code == 302 + assert result.headers['location'] == 'http://localhost:8000/settings/user' + mock_user_auth.refresh.assert_called_once() + mock_set_cookie.assert_called_once() + assert mock_user_auth.email_verified is True + + +@pytest.mark.asyncio +async def test_verified_email_https_scheme(mock_request, mock_user_auth): + """Test verified_email uses https scheme for non-localhost hosts.""" + # Arrange + mock_request.url.hostname = 'example.com' + mock_request.url.netloc = 'example.com' + mock_request.query_params.get.return_value = None + + # Act + with ( + patch('server.routes.email.get_user_auth', return_value=mock_user_auth), + patch('server.routes.email.set_response_cookie') as mock_set_cookie, + ): + result = await verified_email(mock_request) + + # Assert + assert isinstance(result, RedirectResponse) + assert result.headers['location'].startswith('https://') + mock_set_cookie.assert_called_once() + # Verify secure flag is True for https + call_kwargs = mock_set_cookie.call_args.kwargs + assert call_kwargs['secure'] is True diff --git a/enterprise/tests/unit/test_auth_routes.py b/enterprise/tests/unit/test_auth_routes.py index 0eeca12dcf..8490d92760 100644 --- a/enterprise/tests/unit/test_auth_routes.py +++ b/enterprise/tests/unit/test_auth_routes.py @@ -136,6 +136,7 @@ async def test_keycloak_callback_user_not_allowed(mock_request): 'sub': 'test_user_id', 'preferred_username': 'test_user', 'identity_provider': 'github', + 'email_verified': True, } ) mock_token_manager.store_idp_tokens = AsyncMock() @@ -184,6 +185,7 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request): 'sub': 'test_user_id', 'preferred_username': 'test_user', 'identity_provider': 'github', + 'email_verified': True, } ) mock_token_manager.store_idp_tokens = AsyncMock() @@ -214,6 +216,82 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request): mock_posthog.set.assert_called_once() +@pytest.mark.asyncio +async def test_keycloak_callback_email_not_verified(mock_request): + """Test keycloak_callback when email is not verified.""" + # Arrange + mock_verify_email = AsyncMock() + with ( + patch('server.routes.auth.token_manager') as mock_token_manager, + patch('server.routes.auth.user_verifier') as mock_verifier, + patch('server.routes.email.verify_email', mock_verify_email), + ): + mock_token_manager.get_keycloak_tokens = AsyncMock( + return_value=('test_access_token', 'test_refresh_token') + ) + mock_token_manager.get_user_info = AsyncMock( + return_value={ + 'sub': 'test_user_id', + 'preferred_username': 'test_user', + 'identity_provider': 'github', + 'email_verified': False, + } + ) + mock_token_manager.store_idp_tokens = AsyncMock() + mock_verifier.is_active.return_value = False + + # Act + result = await keycloak_callback( + code='test_code', state='test_state', request=mock_request + ) + + # Assert + assert isinstance(result, RedirectResponse) + assert result.status_code == 302 + assert 'email_verification_required=true' in result.headers['location'] + mock_verify_email.assert_called_once_with( + request=mock_request, user_id='test_user_id', is_auth_flow=True + ) + + +@pytest.mark.asyncio +async def test_keycloak_callback_email_not_verified_missing_field(mock_request): + """Test keycloak_callback when email_verified field is missing (defaults to False).""" + # Arrange + mock_verify_email = AsyncMock() + with ( + patch('server.routes.auth.token_manager') as mock_token_manager, + patch('server.routes.auth.user_verifier') as mock_verifier, + patch('server.routes.email.verify_email', mock_verify_email), + ): + mock_token_manager.get_keycloak_tokens = AsyncMock( + return_value=('test_access_token', 'test_refresh_token') + ) + mock_token_manager.get_user_info = AsyncMock( + return_value={ + 'sub': 'test_user_id', + 'preferred_username': 'test_user', + 'identity_provider': 'github', + # email_verified field is missing + } + ) + mock_token_manager.store_idp_tokens = AsyncMock() + mock_verifier.is_active.return_value = False + + # Act + result = await keycloak_callback( + code='test_code', state='test_state', request=mock_request + ) + + # Assert + assert isinstance(result, RedirectResponse) + assert result.status_code == 302 + assert 'email_verification_required=true' in result.headers['location'] + mock_verify_email.assert_called_once_with( + request=mock_request, user_id='test_user_id', is_auth_flow=True + ) + + @pytest.mark.asyncio async def test_keycloak_callback_success_without_offline_token(mock_request): """Test successful keycloak_callback without valid offline token.""" @@ -248,6 +326,7 @@ async def test_keycloak_callback_success_without_offline_token(mock_request): 'sub': 'test_user_id', 'preferred_username': 'test_user', 'identity_provider': 'github', + 'email_verified': True, } ) mock_token_manager.store_idp_tokens = AsyncMock() @@ -513,6 +592,7 @@ async def test_keycloak_callback_allowed_email_domain(mock_request): 'preferred_username': 'test_user', 'email': 'user@example.com', 'identity_provider': 'github', + 'email_verified': True, } ) mock_token_manager.store_idp_tokens = AsyncMock() @@ -566,6 +646,7 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request): 'preferred_username': 'test_user', 'email': 'user@colsch.us', 'identity_provider': 'github', + 'email_verified': True, } ) mock_token_manager.store_idp_tokens = AsyncMock() @@ -615,6 +696,7 @@ async def test_keycloak_callback_missing_email(mock_request): 'sub': 'test_user_id', 'preferred_username': 'test_user', 'identity_provider': 'github', + 'email_verified': True, # No email field } ) @@ -733,6 +815,7 @@ async def test_keycloak_callback_duplicate_check_exception(mock_request): 'preferred_username': 'test_user', 'email': 'joe+test@example.com', 'identity_provider': 'github', + 'email_verified': True, } ) mock_token_manager.check_duplicate_base_email = AsyncMock( @@ -782,6 +865,7 @@ async def test_keycloak_callback_no_duplicate_email(mock_request): 'preferred_username': 'test_user', 'email': 'joe+test@example.com', 'identity_provider': 'github', + 'email_verified': True, } ) mock_token_manager.check_duplicate_base_email = AsyncMock(return_value=False) @@ -833,6 +917,7 @@ async def test_keycloak_callback_no_email_in_user_info(mock_request): 'preferred_username': 'test_user', # No email field 'identity_provider': 'github', + 'email_verified': True, } ) mock_token_manager.store_idp_tokens = AsyncMock() diff --git a/frontend/__tests__/components/features/auth-modal.test.tsx b/frontend/__tests__/components/features/auth-modal.test.tsx index 4f32841b12..30550f7106 100644 --- a/frontend/__tests__/components/features/auth-modal.test.tsx +++ b/frontend/__tests__/components/features/auth-modal.test.tsx @@ -77,7 +77,7 @@ describe("AuthModal", () => { ); // Find the terms of service section using data-testid - const termsSection = screen.getByTestId("auth-modal-terms-of-service"); + const termsSection = screen.getByTestId("terms-and-privacy-notice"); expect(termsSection).toBeInTheDocument(); // Check that all text content is present in the paragraph @@ -114,6 +114,38 @@ describe("AuthModal", () => { expect(termsSection).toContainElement(privacyLink); }); + it("should display email verified message when emailVerified prop is true", () => { + render( + + + , + ); + + expect( + screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"), + ).toBeInTheDocument(); + }); + + it("should not display email verified message when emailVerified prop is false", () => { + render( + + + , + ); + + expect( + screen.queryByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"), + ).not.toBeInTheDocument(); + }); + it("should open Terms of Service link in new tab", () => { render( @@ -142,12 +174,17 @@ describe("AuthModal", () => { describe("Duplicate email error message", () => { const renderAuthModalWithRouter = (initialEntries: string[]) => { + const hasDuplicatedEmail = initialEntries.includes( + "/?duplicated_email=true", + ); + return render( , ); diff --git a/frontend/__tests__/components/features/waitlist/email-verification-modal.test.tsx b/frontend/__tests__/components/features/waitlist/email-verification-modal.test.tsx new file mode 100644 index 0000000000..e773461d84 --- /dev/null +++ b/frontend/__tests__/components/features/waitlist/email-verification-modal.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { it, describe, expect, vi, beforeEach } from "vitest"; +import { EmailVerificationModal } from "#/components/features/waitlist/email-verification-modal"; + +describe("EmailVerificationModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the email verification message", () => { + // Arrange & Act + render(); + + // Assert + expect( + screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"), + ).toBeInTheDocument(); + }); + + it("should render the TermsAndPrivacyNotice component", () => { + // Arrange & Act + render(); + + // Assert + const termsSection = screen.getByTestId("terms-and-privacy-notice"); + expect(termsSection).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/shared/terms-and-privacy-notice.test.tsx b/frontend/__tests__/components/shared/terms-and-privacy-notice.test.tsx new file mode 100644 index 0000000000..559a7f0df6 --- /dev/null +++ b/frontend/__tests__/components/shared/terms-and-privacy-notice.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { it, describe, expect } from "vitest"; +import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice"; + +describe("TermsAndPrivacyNotice", () => { + it("should render Terms of Service and Privacy Policy links", () => { + // Arrange & Act + render(); + + // Assert + const termsSection = screen.getByTestId("terms-and-privacy-notice"); + expect(termsSection).toBeInTheDocument(); + + const tosLink = screen.getByRole("link", { + name: "COMMON$TERMS_OF_SERVICE", + }); + const privacyLink = screen.getByRole("link", { + name: "COMMON$PRIVACY_POLICY", + }); + + expect(tosLink).toBeInTheDocument(); + expect(tosLink).toHaveAttribute("href", "https://www.all-hands.dev/tos"); + expect(tosLink).toHaveAttribute("target", "_blank"); + expect(tosLink).toHaveAttribute("rel", "noopener noreferrer"); + + expect(privacyLink).toBeInTheDocument(); + expect(privacyLink).toHaveAttribute( + "href", + "https://www.all-hands.dev/privacy", + ); + expect(privacyLink).toHaveAttribute("target", "_blank"); + expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should render all required text content", () => { + // Arrange & Act + render(); + + // Assert + const termsSection = screen.getByTestId("terms-and-privacy-notice"); + expect(termsSection).toHaveTextContent( + "AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR", + ); + expect(termsSection).toHaveTextContent("COMMON$TERMS_OF_SERVICE"); + expect(termsSection).toHaveTextContent("COMMON$AND"); + expect(termsSection).toHaveTextContent("COMMON$PRIVACY_POLICY"); + }); +}); diff --git a/frontend/__tests__/routes/root-layout.test.tsx b/frontend/__tests__/routes/root-layout.test.tsx new file mode 100644 index 0000000000..22de4ae616 --- /dev/null +++ b/frontend/__tests__/routes/root-layout.test.tsx @@ -0,0 +1,242 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { it, describe, expect, vi, beforeEach, afterEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createRoutesStub } from "react-router"; +import MainApp from "#/routes/root-layout"; +import OptionService from "#/api/option-service/option-service.api"; +import AuthService from "#/api/auth-service/auth-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; + +// Mock other hooks that are not the focus of these tests +vi.mock("#/hooks/use-github-auth-url", () => ({ + useGitHubAuthUrl: () => "https://github.com/oauth/authorize", +})); + +vi.mock("#/hooks/use-is-on-tos-page", () => ({ + useIsOnTosPage: () => false, +})); + +vi.mock("#/hooks/use-auto-login", () => ({ + useAutoLogin: () => {}, +})); + +vi.mock("#/hooks/use-auth-callback", () => ({ + useAuthCallback: () => {}, +})); + +vi.mock("#/hooks/use-migrate-user-consent", () => ({ + useMigrateUserConsent: () => ({ + migrateUserConsent: vi.fn(), + }), +})); + +vi.mock("#/hooks/use-reo-tracking", () => ({ + useReoTracking: () => {}, +})); + +vi.mock("#/hooks/use-sync-posthog-consent", () => ({ + useSyncPostHogConsent: () => {}, +})); + +vi.mock("#/utils/custom-toast-handlers", () => ({ + displaySuccessToast: vi.fn(), +})); + +const RouterStub = createRoutesStub([ + { + Component: MainApp, + path: "/", + children: [ + { + Component: () =>
Content
, + path: "/", + }, + ], + }, +]); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe("MainApp - Email Verification Flow", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mocks for services + vi.spyOn(OptionService, "getConfig").mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "test-client-id", + POSTHOG_CLIENT_KEY: "test-posthog-key", + PROVIDERS_CONFIGURED: ["github"], + AUTH_URL: "https://auth.example.com", + FEATURE_FLAGS: { + ENABLE_BILLING: false, + HIDE_LLM_SETTINGS: false, + ENABLE_JIRA: false, + ENABLE_JIRA_DC: false, + ENABLE_LINEAR: false, + }, + }); + + vi.spyOn(AuthService, "authenticate").mockResolvedValue(true); + + vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ + language: "en", + user_consents_to_analytics: true, + llm_model: "", + llm_base_url: "", + agent: "", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: null, + remote_runtime_resource_factor: null, + provider_tokens_set: {}, + enable_default_condenser: false, + condenser_max_size: null, + enable_sound_notifications: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + max_budget_per_task: null, + }); + + // Mock localStorage + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should display EmailVerificationModal when email_verification_required=true is in query params", async () => { + // Arrange & Act + render( + , + { wrapper: createWrapper() }, + ); + + // Assert + await waitFor(() => { + expect( + screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"), + ).toBeInTheDocument(); + }); + }); + + it("should set emailVerified state and pass to AuthModal when email_verified=true is in query params", async () => { + // Arrange + // Mock a 401 error to simulate unauthenticated user + const axiosError = { + response: { status: 401 }, + isAxiosError: true, + }; + vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError); + + // Act + render(, { + wrapper: createWrapper(), + }); + + // Assert - Wait for AuthModal to render (since user is not authenticated) + await waitFor(() => { + expect( + screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"), + ).toBeInTheDocument(); + }); + }); + + it("should handle both email_verification_required and email_verified params together", async () => { + // Arrange & Act + render( + , + { wrapper: createWrapper() }, + ); + + // Assert - EmailVerificationModal should take precedence + await waitFor(() => { + expect( + screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"), + ).toBeInTheDocument(); + }); + }); + + it("should remove query parameters from URL after processing", async () => { + // Arrange & Act + const { container } = render( + , + { wrapper: createWrapper() }, + ); + + // Assert - Wait for the modal to appear (which indicates processing happened) + await waitFor(() => { + expect( + screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"), + ).toBeInTheDocument(); + }); + + // Verify that the query parameter was processed by checking the modal appeared + // The hook removes the parameter from the URL, so we verify the behavior indirectly + expect(container).toBeInTheDocument(); + }); + + it("should not display EmailVerificationModal when email_verification_required is not in query params", async () => { + // Arrange - No query params set + + // Act + render(, { wrapper: createWrapper() }); + + // Assert + await waitFor(() => { + expect( + screen.queryByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"), + ).not.toBeInTheDocument(); + }); + }); + + it("should not display email verified message when email_verified is not in query params", async () => { + // Arrange + // Mock a 401 error to simulate unauthenticated user + const axiosError = { + response: { status: 401 }, + isAxiosError: true, + }; + vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError); + + // Act + render(, { wrapper: createWrapper() }); + + // Assert - AuthModal should render but without email verified message + await waitFor(() => { + const authModal = screen.queryByText( + "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER", + ); + if (authModal) { + expect( + screen.queryByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"), + ).not.toBeInTheDocument(); + } + }); + }); +}); diff --git a/frontend/src/components/features/waitlist/auth-modal.tsx b/frontend/src/components/features/waitlist/auth-modal.tsx index e1d52a7965..6d92cb4dfc 100644 --- a/frontend/src/components/features/waitlist/auth-modal.tsx +++ b/frontend/src/components/features/waitlist/auth-modal.tsx @@ -1,6 +1,5 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { useSearchParams } from "react-router"; import { I18nKey } from "#/i18n/declaration"; import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; @@ -14,12 +13,15 @@ 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 { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice"; interface AuthModalProps { githubAuthUrl: string | null; appMode?: GetConfigResponse["APP_MODE"] | null; authUrl?: GetConfigResponse["AUTH_URL"]; providersConfigured?: Provider[]; + emailVerified?: boolean; + hasDuplicatedEmail?: boolean; } export function AuthModal({ @@ -27,11 +29,11 @@ export function AuthModal({ appMode, authUrl, providersConfigured, + emailVerified = false, + hasDuplicatedEmail = false, }: AuthModalProps) { const { t } = useTranslation(); const { trackLoginButtonClick } = useTracking(); - const [searchParams] = useSearchParams(); - const hasDuplicatedEmail = searchParams.get("duplicated_email") === "true"; const gitlabAuthUrl = useAuthUrl({ appMode: appMode || null, @@ -126,6 +128,13 @@ export function AuthModal({ + {emailVerified && ( +
+

+ {t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)} +

+
+ )} {hasDuplicatedEmail && (
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)} @@ -206,30 +215,7 @@ export function AuthModal({ )}
-

- {t(I18nKey.AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR)}{" "} - - {t(I18nKey.COMMON$TERMS_OF_SERVICE)} - {" "} - {t(I18nKey.COMMON$AND)}{" "} - - {t(I18nKey.COMMON$PRIVACY_POLICY)} - - . -

+
); diff --git a/frontend/src/components/features/waitlist/email-verification-modal.tsx b/frontend/src/components/features/waitlist/email-verification-modal.tsx new file mode 100644 index 0000000000..820dce3258 --- /dev/null +++ b/frontend/src/components/features/waitlist/email-verification-modal.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice"; + +interface EmailVerificationModalProps { + onClose: () => void; +} + +export function EmailVerificationModal({ + onClose, +}: EmailVerificationModalProps) { + const { t } = useTranslation(); + + return ( + + + +
+

+ {t(I18nKey.AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY)} +

+
+ + +
+
+ ); +} diff --git a/frontend/src/components/shared/terms-and-privacy-notice.tsx b/frontend/src/components/shared/terms-and-privacy-notice.tsx new file mode 100644 index 0000000000..8293d734da --- /dev/null +++ b/frontend/src/components/shared/terms-and-privacy-notice.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + +interface TermsAndPrivacyNoticeProps { + className?: string; +} + +export function TermsAndPrivacyNotice({ + className = "mt-4 text-xs text-center text-muted-foreground", +}: TermsAndPrivacyNoticeProps) { + const { t } = useTranslation(); + + return ( +

+ {t(I18nKey.AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR)}{" "} + + {t(I18nKey.COMMON$TERMS_OF_SERVICE)} + {" "} + {t(I18nKey.COMMON$AND)}{" "} + + {t(I18nKey.COMMON$PRIVACY_POLICY)} + + . +

+ ); +} diff --git a/frontend/src/hooks/use-email-verification.ts b/frontend/src/hooks/use-email-verification.ts new file mode 100644 index 0000000000..c0068395b5 --- /dev/null +++ b/frontend/src/hooks/use-email-verification.ts @@ -0,0 +1,63 @@ +import React from "react"; +import { useSearchParams } from "react-router"; + +/** + * Hook to handle email verification logic from URL query parameters. + * Manages the email verification modal state and email verified state + * based on query parameters in the URL. + * + * @returns An object containing: + * - emailVerificationModalOpen: boolean state for modal visibility + * - setEmailVerificationModalOpen: function to control modal visibility + * - emailVerified: boolean state for email verification status + * - setEmailVerified: function to control email verification status + * - hasDuplicatedEmail: boolean state for duplicate email error status + */ +export function useEmailVerification() { + const [searchParams, setSearchParams] = useSearchParams(); + const [emailVerificationModalOpen, setEmailVerificationModalOpen] = + React.useState(false); + const [emailVerified, setEmailVerified] = React.useState(false); + const [hasDuplicatedEmail, setHasDuplicatedEmail] = React.useState(false); + + // Check for email verification query parameters + React.useEffect(() => { + const emailVerificationRequired = searchParams.get( + "email_verification_required", + ); + const emailVerifiedParam = searchParams.get("email_verified"); + const duplicatedEmailParam = searchParams.get("duplicated_email"); + let shouldUpdate = false; + + if (emailVerificationRequired === "true") { + setEmailVerificationModalOpen(true); + searchParams.delete("email_verification_required"); + shouldUpdate = true; + } + + if (emailVerifiedParam === "true") { + setEmailVerified(true); + searchParams.delete("email_verified"); + shouldUpdate = true; + } + + if (duplicatedEmailParam === "true") { + setHasDuplicatedEmail(true); + searchParams.delete("duplicated_email"); + shouldUpdate = true; + } + + // Clean up the URL by removing parameters if any were found + if (shouldUpdate) { + setSearchParams(searchParams, { replace: true }); + } + }, [searchParams, setSearchParams]); + + return { + emailVerificationModalOpen, + setEmailVerificationModalOpen, + emailVerified, + setEmailVerified, + hasDuplicatedEmail, + }; +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 0dd668cacc..e3ed93db2f 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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$PLEASE_CHECK_EMAIL_TO_VERIFY = "AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY", + AUTH$EMAIL_VERIFIED_PLEASE_LOGIN = "AUTH$EMAIL_VERIFIED_PLEASE_LOGIN", AUTH$DUPLICATE_EMAIL_ERROR = "AUTH$DUPLICATE_EMAIL_ERROR", COMMON$TERMS_OF_SERVICE = "COMMON$TERMS_OF_SERVICE", COMMON$AND = "COMMON$AND", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 2950b3ab72..81df3b6f7d 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -11679,6 +11679,38 @@ "de": "Mindestens ein Identitätsanbieter muss konfiguriert werden (z.B. GitHub)", "uk": "Принаймні один постачальник ідентифікації має бути налаштований (наприклад, GitHub)" }, + "AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY": { + "en": "Please check your email to verify your account.", + "ja": "アカウントを確認するためにメールを確認してください。", + "zh-CN": "请检查您的电子邮件以验证您的账户。", + "zh-TW": "請檢查您的電子郵件以驗證您的帳戶。", + "ko-KR": "계정을 확인하려면 이메일을 확인하세요.", + "no": "Vennligst sjekk e-posten din for å bekrefte kontoen din.", + "it": "Controlla la tua email per verificare il tuo account.", + "pt": "Por favor, verifique seu e-mail para verificar sua conta.", + "es": "Por favor, verifica tu correo electrónico para verificar tu cuenta.", + "ar": "يرجى التحقق من بريدك الإلكتروني للتحقق من حسابك.", + "fr": "Veuillez vérifier votre e-mail pour vérifier votre compte.", + "tr": "Hesabınızı doğrulamak için lütfen e-postanızı kontrol edin.", + "de": "Bitte überprüfen Sie Ihre E-Mail, um Ihr Konto zu verifizieren.", + "uk": "Будь ласка, перевірте свою електронну пошту, щоб підтвердити свій обліковий запис." + }, + "AUTH$EMAIL_VERIFIED_PLEASE_LOGIN": { + "en": "Your email has been verified. Please login below.", + "ja": "メールアドレスが確認されました。下記からログインしてください。", + "zh-CN": "您的电子邮件已验证。请在下方登录。", + "zh-TW": "您的電子郵件已驗證。請在下方登錄。", + "ko-KR": "이메일이 확인되었습니다. 아래에서 로그인하세요.", + "no": "E-posten din er bekreftet. Vennligst logg inn nedenfor.", + "it": "La tua email è stata verificata. Effettua il login qui sotto.", + "pt": "Seu e-mail foi verificado. Por favor, faça login abaixo.", + "es": "Tu correo electrónico ha sido verificado. Por favor, inicia sesión a continuación.", + "ar": "تم التحقق من بريدك الإلكتروني. يرجى تسجيل الدخول أدناه.", + "fr": "Votre e-mail a été vérifié. Veuillez vous connecter ci-dessous.", + "tr": "E-postanız doğrulandı. Lütfen aşağıdan giriş yapın.", + "de": "Ihre E-Mail wurde verifiziert. Bitte melden Sie sich unten an.", + "uk": "Вашу електронну пошту підтверджено. Будь ласка, увійдіть нижче." + }, "AUTH$DUPLICATE_EMAIL_ERROR": { "en": "Your account is unable to be created. Please use a different login or try again.", "ja": "アカウントを作成できません。別のログインを使用するか、もう一度お試しください。", diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 876c4d8c11..73da04ea8f 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -15,6 +15,7 @@ import { useConfig } from "#/hooks/query/use-config"; import { Sidebar } from "#/components/features/sidebar/sidebar"; import { AuthModal } from "#/components/features/waitlist/auth-modal"; import { ReauthModal } from "#/components/features/waitlist/reauth-modal"; +import { EmailVerificationModal } from "#/components/features/waitlist/email-verification-modal"; import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal"; import { useSettings } from "#/hooks/query/use-settings"; import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent"; @@ -26,6 +27,7 @@ import { useAutoLogin } from "#/hooks/use-auto-login"; import { useAuthCallback } from "#/hooks/use-auth-callback"; import { useReoTracking } from "#/hooks/use-reo-tracking"; import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent"; +import { useEmailVerification } from "#/hooks/use-email-verification"; import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage"; import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard"; import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner"; @@ -91,6 +93,12 @@ export default function MainApp() { const effectiveGitHubAuthUrl = isOnTosPage ? null : gitHubAuthUrl; const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false); + const { + emailVerificationModalOpen, + setEmailVerificationModalOpen, + emailVerified, + hasDuplicatedEmail, + } = useEmailVerification(); // Auto-login if login method is stored in local storage useAutoLogin(); @@ -236,9 +244,18 @@ export default function MainApp() { appMode={config.data?.APP_MODE} providersConfigured={config.data?.PROVIDERS_CONFIGURED} authUrl={config.data?.AUTH_URL} + emailVerified={emailVerified} + hasDuplicatedEmail={hasDuplicatedEmail} /> )} {renderReAuthModal && } + {emailVerificationModalOpen && ( + { + setEmailVerificationModalOpen(false); + }} + /> + )} {config.data?.APP_MODE === "oss" && consentFormIsOpen && ( {