feat: require email verification for new signups (#12123)

This commit is contained in:
Hiep Le 2025-12-24 14:56:02 +07:00 committed by GitHub
parent dc99c7b62e
commit e2b2aa52cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 810 additions and 33 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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()

View File

@ -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(
<MemoryRouter>
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
emailVerified={true}
/>
</MemoryRouter>,
);
expect(
screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
).toBeInTheDocument();
});
it("should not display email verified message when emailVerified prop is false", () => {
render(
<MemoryRouter>
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
emailVerified={false}
/>
</MemoryRouter>,
);
expect(
screen.queryByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
).not.toBeInTheDocument();
});
it("should open Terms of Service link in new tab", () => {
render(
<MemoryRouter>
@ -142,12 +174,17 @@ describe("AuthModal", () => {
describe("Duplicate email error message", () => {
const renderAuthModalWithRouter = (initialEntries: string[]) => {
const hasDuplicatedEmail = initialEntries.includes(
"/?duplicated_email=true",
);
return render(
<MemoryRouter initialEntries={initialEntries}>
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github"]}
hasDuplicatedEmail={hasDuplicatedEmail}
/>
</MemoryRouter>,
);

View File

@ -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(<EmailVerificationModal onClose={vi.fn()} />);
// Assert
expect(
screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
).toBeInTheDocument();
});
it("should render the TermsAndPrivacyNotice component", () => {
// Arrange & Act
render(<EmailVerificationModal onClose={vi.fn()} />);
// Assert
const termsSection = screen.getByTestId("terms-and-privacy-notice");
expect(termsSection).toBeInTheDocument();
});
});

View File

@ -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(<TermsAndPrivacyNotice />);
// 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(<TermsAndPrivacyNotice />);
// 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");
});
});

View File

@ -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: () => <div data-testid="outlet-content">Content</div>,
path: "/",
},
],
},
]);
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
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(
<RouterStub initialEntries={["/?email_verification_required=true"]} />,
{ 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(<RouterStub initialEntries={["/?email_verified=true"]} />, {
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(
<RouterStub
initialEntries={[
"/?email_verification_required=true&email_verified=true",
]}
/>,
{ 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(
<RouterStub initialEntries={["/?email_verification_required=true"]} />,
{ 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(<RouterStub />, { 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(<RouterStub />, { 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();
}
});
});
});

View File

@ -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({
<ModalBackdrop>
<ModalBody className="border border-tertiary">
<OpenHandsLogo width={68} height={46} />
{emailVerified && (
<div className="flex flex-col gap-2 w-full items-center text-center">
<p className="text-sm text-muted-foreground">
{t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)}
</p>
</div>
)}
{hasDuplicatedEmail && (
<div className="text-center text-danger text-sm mt-2 mb-2">
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
@ -206,30 +215,7 @@ export function AuthModal({
)}
</div>
<p
className="mt-4 text-xs text-center text-muted-foreground"
data-testid="auth-modal-terms-of-service"
>
{t(I18nKey.AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR)}{" "}
<a
href="https://www.all-hands.dev/tos"
target="_blank"
className="underline hover:text-primary"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$TERMS_OF_SERVICE)}
</a>{" "}
{t(I18nKey.COMMON$AND)}{" "}
<a
href="https://www.all-hands.dev/privacy"
target="_blank"
className="underline hover:text-primary"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$PRIVACY_POLICY)}
</a>
.
</p>
<TermsAndPrivacyNotice />
</ModalBody>
</ModalBackdrop>
);

View File

@ -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 (
<ModalBackdrop onClose={onClose}>
<ModalBody className="border border-tertiary">
<OpenHandsLogo width={68} height={46} />
<div className="flex flex-col gap-2 w-full items-center text-center">
<h1 className="text-2xl font-bold">
{t(I18nKey.AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY)}
</h1>
</div>
<TermsAndPrivacyNotice />
</ModalBody>
</ModalBackdrop>
);
}

View File

@ -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 (
<p className={className} data-testid="terms-and-privacy-notice">
{t(I18nKey.AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR)}{" "}
<a
href="https://www.all-hands.dev/tos"
target="_blank"
className="underline hover:text-primary"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$TERMS_OF_SERVICE)}
</a>{" "}
{t(I18nKey.COMMON$AND)}{" "}
<a
href="https://www.all-hands.dev/privacy"
target="_blank"
className="underline hover:text-primary"
rel="noopener noreferrer"
>
{t(I18nKey.COMMON$PRIVACY_POLICY)}
</a>
.
</p>
);
}

View File

@ -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,
};
}

View File

@ -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",

View File

@ -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": "アカウントを作成できません。別のログインを使用するか、もう一度お試しください。",

View File

@ -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 && <ReauthModal />}
{emailVerificationModalOpen && (
<EmailVerificationModal
onClose={() => {
setEmailVerificationModalOpen(false);
}}
/>
)}
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (
<AnalyticsConsentFormModal
onClose={() => {