feat: add button to authentication modal to resend verification email

This commit is contained in:
hieptl
2025-12-27 15:23:11 +07:00
parent cb1d1f8a0d
commit 18e76e0594
15 changed files with 949 additions and 29 deletions

View File

@@ -159,6 +159,7 @@ class SetAuthCookieMiddleware:
'/api/billing/cancel',
'/api/billing/customer-setup-success',
'/api/billing/stripe-webhook',
'/api/email/resend',
'/oauth/device/authorize',
'/oauth/device/token',
)

View File

@@ -210,7 +210,9 @@ async def keycloak_callback(
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'
redirect_url = (
f'{request.base_url}?email_verification_required=true&user_id={user_id}'
)
response = RedirectResponse(redirect_url, status_code=302)
return response

View File

@@ -7,6 +7,7 @@ from server.auth.constants import KEYCLOAK_CLIENT_ID
from server.auth.keycloak_manager import get_keycloak_admin
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.auth import set_response_cookie
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
@@ -28,6 +29,11 @@ class EmailUpdate(BaseModel):
return v
class ResendEmailVerificationRequest(BaseModel):
user_id: str | None = None
is_auth_flow: bool = False
@api_router.post('')
async def update_email(
email_data: EmailUpdate, request: Request, user_id: str = Depends(get_user_id)
@@ -90,11 +96,36 @@ async def update_email(
)
@api_router.put('/verify')
@api_router.put('/resend')
async def resend_email_verification(
request: Request, user_id: str = Depends(get_user_id)
request: Request,
body: ResendEmailVerificationRequest | None = None,
):
await verify_email(request=request, user_id=user_id)
# Get user_id from body if provided, otherwise from auth
user_id: str | None = None
if body and body.user_id:
user_id = body.user_id
else:
try:
user_id = await get_user_id(request)
except Exception:
pass
if not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='user_id is required in request body or user must be authenticated',
)
# Check rate limit (uses user_id if available, otherwise falls back to IP)
await check_rate_limit_by_user_id(
request=request, key_prefix='email_resend', user_id=user_id
)
# Get is_auth_flow from body if provided, default to False
is_auth_flow = body.is_auth_flow if body else False
await verify_email(request=request, user_id=user_id, is_auth_flow=is_auth_flow)
logger.info(f'Resending verification email for {user_id}')
return JSONResponse(
@@ -129,11 +160,10 @@ async def verified_email(request: Request):
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}?email_verified=true'
if is_auth_flow
else f'{scheme}://{request.url.netloc}/api/email/verified'
)
if is_auth_flow:
redirect_uri = f'{scheme}://{request.url.netloc}?email_verified=true'
else:
redirect_uri = 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,73 @@
from fastapi import HTTPException, Request, status
from openhands.core.logger import openhands_logger as logger
from openhands.server.shared import sio
# Rate limiting constants
RATE_LIMIT_USER_SECONDS = 120 # 2 minutes per user_id
RATE_LIMIT_IP_SECONDS = 300 # 5 minutes per IP address
async def check_rate_limit_by_user_id(
request: Request,
key_prefix: str,
user_id: str | None,
user_rate_limit_seconds: int = RATE_LIMIT_USER_SECONDS,
ip_rate_limit_seconds: int = RATE_LIMIT_IP_SECONDS,
) -> None:
"""
Check rate limit for requests, using user_id when available, falling back to IP address.
Uses Redis to store rate limit keys with expiration. If a key already exists,
it means the rate limit is active and the request will be rejected.
Args:
request: FastAPI Request object
key_prefix: Prefix for the Redis key (e.g., "email_resend")
user_id: User ID if available, None otherwise
user_rate_limit_seconds: Rate limit window in seconds for user_id-based limiting (default: 120)
ip_rate_limit_seconds: Rate limit window in seconds for IP-based limiting (default: 300)
Raises:
HTTPException: If rate limit is exceeded (429 status code)
"""
try:
redis = sio.manager.redis
if not redis:
# If Redis is unavailable, log warning and allow request (fail open)
logger.warning('Redis unavailable for rate limiting, allowing request')
return
if user_id:
# Rate limit by user_id (primary method)
rate_limit_key = f'{key_prefix}:{user_id}'
rate_limit_seconds = user_rate_limit_seconds
else:
# Fallback to IP address rate limiting
client_ip = request.client.host if request.client else 'unknown'
rate_limit_key = f'{key_prefix}:ip:{client_ip}'
rate_limit_seconds = ip_rate_limit_seconds
# Try to set the key with expiration. If it already exists (nx=True fails),
# it means the rate limit is active
created = await redis.set(rate_limit_key, 1, nx=True, ex=rate_limit_seconds)
if not created:
logger.info(
f'Rate limit exceeded for {rate_limit_key}',
extra={
'user_id': user_id,
'ip': request.client.host if request.client else 'unknown',
},
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f'Too many requests. Please wait {rate_limit_seconds // 60} minutes before trying again.',
)
except HTTPException:
# Re-raise HTTPException (rate limit exceeded)
raise
except Exception as e:
# Log error but allow request (fail open) to avoid blocking legitimate users
logger.warning(f'Error checking rate limit: {e}', exc_info=True)
return

View File

@@ -1,11 +1,16 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import Request
from fastapi.responses import RedirectResponse
from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.email import verified_email, verify_email
from server.routes.email import (
ResendEmailVerificationRequest,
resend_email_verification,
verified_email,
verify_email,
)
@pytest.fixture
@@ -149,3 +154,196 @@ async def test_verified_email_https_scheme(mock_request, mock_user_auth):
# Verify secure flag is True for https
call_kwargs = mock_set_cookie.call_args.kwargs
assert call_kwargs['secure'] is True
@pytest.mark.asyncio
async def test_resend_email_verification_with_user_id_from_body_succeeds(mock_request):
"""Test resend_email_verification succeeds when user_id is provided in body."""
# Arrange
user_id = 'test_user_id'
body = ResendEmailVerificationRequest(user_id=user_id, is_auth_flow=False)
mock_keycloak_admin = AsyncMock()
mock_keycloak_admin.a_send_verify_email = AsyncMock()
with (
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
patch(
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
),
patch('server.routes.email.logger') as mock_logger,
):
mock_rate_limit.return_value = None # Rate limit check passes
# Act
result = await resend_email_verification(request=mock_request, body=body)
# Assert
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_200_OK
assert 'message' in result.body.decode()
mock_rate_limit.assert_called_once_with(
request=mock_request, key_prefix='email_resend', user_id=user_id
)
mock_keycloak_admin.a_send_verify_email.assert_called_once()
# Logger is called multiple times (verify_email and resend_email_verification)
# Check that the resend message was logged
assert any(
'Resending verification email for' in str(call)
for call in mock_logger.info.call_args_list
)
@pytest.mark.asyncio
async def test_resend_email_verification_with_user_id_from_auth_succeeds(mock_request):
"""Test resend_email_verification succeeds when user_id comes from authentication."""
# Arrange
user_id = 'test_user_id'
mock_keycloak_admin = AsyncMock()
mock_keycloak_admin.a_send_verify_email = AsyncMock()
with (
patch(
'server.routes.email.get_user_id', return_value=user_id
) as mock_get_user_id,
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
patch(
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
),
):
mock_rate_limit.return_value = None # Rate limit check passes
# Act
result = await resend_email_verification(request=mock_request, body=None)
# Assert
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_200_OK
mock_get_user_id.assert_called_once_with(mock_request)
mock_rate_limit.assert_called_once_with(
request=mock_request, key_prefix='email_resend', user_id=user_id
)
@pytest.mark.asyncio
async def test_resend_email_verification_without_user_id_returns_400(mock_request):
"""Test resend_email_verification returns 400 when user_id is not available."""
# Arrange
with patch(
'server.routes.email.get_user_id', side_effect=Exception('Not authenticated')
):
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await resend_email_verification(request=mock_request, body=None)
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
assert 'user_id is required' in exc_info.value.detail
@pytest.mark.asyncio
async def test_resend_email_verification_rate_limit_exceeded_returns_429(mock_request):
"""Test resend_email_verification returns 429 when rate limit is exceeded."""
# Arrange
user_id = 'test_user_id'
body = ResendEmailVerificationRequest(user_id=user_id)
with (
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
):
mock_rate_limit.side_effect = HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests. Please wait 2 minutes before trying again.',
)
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await resend_email_verification(request=mock_request, body=body)
assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS
assert 'Too many requests' in exc_info.value.detail
mock_rate_limit.assert_called_once()
@pytest.mark.asyncio
async def test_resend_email_verification_with_is_auth_flow_true(mock_request):
"""Test resend_email_verification passes is_auth_flow to verify_email."""
# Arrange
user_id = 'test_user_id'
body = ResendEmailVerificationRequest(user_id=user_id, is_auth_flow=True)
mock_keycloak_admin = AsyncMock()
mock_keycloak_admin.a_send_verify_email = AsyncMock()
with (
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
patch(
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
),
):
mock_rate_limit.return_value = None
# Act
await resend_email_verification(request=mock_request, body=body)
# Assert
mock_keycloak_admin.a_send_verify_email.assert_called_once()
call_args = mock_keycloak_admin.a_send_verify_email.call_args
# Verify that verify_email was called with is_auth_flow=True
# We check this indirectly by verifying the redirect_uri
assert 'email_verified=true' in call_args.kwargs['redirect_uri']
@pytest.mark.asyncio
async def test_resend_email_verification_with_is_auth_flow_false(mock_request):
"""Test resend_email_verification uses default is_auth_flow=False when not specified."""
# Arrange
user_id = 'test_user_id'
body = ResendEmailVerificationRequest(user_id=user_id, is_auth_flow=False)
mock_keycloak_admin = AsyncMock()
mock_keycloak_admin.a_send_verify_email = AsyncMock()
with (
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
patch(
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
),
):
mock_rate_limit.return_value = None
# Act
await resend_email_verification(request=mock_request, body=body)
# Assert
mock_keycloak_admin.a_send_verify_email.assert_called_once()
call_args = mock_keycloak_admin.a_send_verify_email.call_args
# Verify that verify_email was called with is_auth_flow=False
assert '/api/email/verified' in call_args.kwargs['redirect_uri']
@pytest.mark.asyncio
async def test_resend_email_verification_body_none_uses_auth(mock_request):
"""Test resend_email_verification uses auth when body is None."""
# Arrange
user_id = 'test_user_id'
mock_keycloak_admin = AsyncMock()
mock_keycloak_admin.a_send_verify_email = AsyncMock()
with (
patch(
'server.routes.email.get_user_id', return_value=user_id
) as mock_get_user_id,
patch('server.routes.email.check_rate_limit_by_user_id') as mock_rate_limit,
patch(
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
),
):
mock_rate_limit.return_value = None
# Act
result = await resend_email_verification(request=mock_request, body=None)
# Assert
assert isinstance(result, JSONResponse)
assert result.status_code == status.HTTP_200_OK
mock_get_user_id.assert_called_once()
mock_rate_limit.assert_called_once_with(
request=mock_request, key_prefix='email_resend', user_id=user_id
)

View File

@@ -0,0 +1,290 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException, Request, status
from server.utils.rate_limit_utils import (
RATE_LIMIT_IP_SECONDS,
RATE_LIMIT_USER_SECONDS,
check_rate_limit_by_user_id,
)
@pytest.fixture
def mock_request():
"""Create a mock request object."""
request = MagicMock(spec=Request)
request.client = MagicMock()
request.client.host = '192.168.1.1'
return request
@pytest.fixture
def mock_redis():
"""Create a mock Redis client."""
redis = AsyncMock()
redis.set = AsyncMock(return_value=True) # First call succeeds (key doesn't exist)
return redis
@pytest.mark.asyncio
async def test_rate_limit_by_user_id_first_request_succeeds(mock_request, mock_redis):
"""Test that first request with user_id succeeds and sets rate limit key."""
# Arrange
user_id = 'test_user_id'
key_prefix = 'email_resend'
with (
patch('server.utils.rate_limit_utils.sio') as mock_sio,
patch('server.utils.rate_limit_utils.logger') as mock_logger,
):
mock_sio.manager.redis = mock_redis
# Act
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=user_id
)
# Assert
mock_redis.set.assert_called_once_with(
f'{key_prefix}:{user_id}', 1, nx=True, ex=RATE_LIMIT_USER_SECONDS
)
mock_logger.warning.assert_not_called()
mock_logger.info.assert_not_called()
@pytest.mark.asyncio
async def test_rate_limit_by_user_id_second_request_within_window_fails(
mock_request, mock_redis
):
"""Test that second request with same user_id within rate limit window fails."""
# Arrange
user_id = 'test_user_id'
key_prefix = 'email_resend'
mock_redis.set = AsyncMock(return_value=False) # Key already exists
with (
patch('server.utils.rate_limit_utils.sio') as mock_sio,
patch('server.utils.rate_limit_utils.logger') as mock_logger,
):
mock_sio.manager.redis = mock_redis
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=user_id
)
assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS
assert 'Too many requests' in exc_info.value.detail
assert f'{RATE_LIMIT_USER_SECONDS // 60} minutes' in exc_info.value.detail
mock_logger.info.assert_called_once()
@pytest.mark.asyncio
async def test_rate_limit_by_ip_when_user_id_is_none(mock_request, mock_redis):
"""Test that rate limiting falls back to IP address when user_id is None."""
# Arrange
key_prefix = 'email_resend'
with (
patch('server.utils.rate_limit_utils.sio') as mock_sio,
patch('server.utils.rate_limit_utils.logger') as mock_logger,
):
mock_sio.manager.redis = mock_redis
# Act
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=None
)
# Assert
mock_redis.set.assert_called_once_with(
f'{key_prefix}:ip:{mock_request.client.host}',
1,
nx=True,
ex=RATE_LIMIT_IP_SECONDS,
)
mock_logger.warning.assert_not_called()
@pytest.mark.asyncio
async def test_rate_limit_by_ip_second_request_within_window_fails(
mock_request, mock_redis
):
"""Test that second request from same IP within rate limit window fails."""
# Arrange
key_prefix = 'email_resend'
mock_redis.set = AsyncMock(return_value=False) # Key already exists
with (
patch('server.utils.rate_limit_utils.sio') as mock_sio,
):
mock_sio.manager.redis = mock_redis
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=None
)
assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS
assert f'{RATE_LIMIT_IP_SECONDS // 60} minutes' in exc_info.value.detail
@pytest.mark.asyncio
async def test_rate_limit_redis_unavailable_fails_open(mock_request):
"""Test that rate limiting fails open when Redis is unavailable."""
# Arrange
key_prefix = 'email_resend'
user_id = 'test_user_id'
with (
patch('server.utils.rate_limit_utils.sio') as mock_sio,
patch('server.utils.rate_limit_utils.logger') as mock_logger,
):
mock_sio.manager.redis = None # Redis unavailable
# Act
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=user_id
)
# Assert
mock_logger.warning.assert_called_once_with(
'Redis unavailable for rate limiting, allowing request'
)
@pytest.mark.asyncio
async def test_rate_limit_redis_exception_fails_open(mock_request, mock_redis):
"""Test that rate limiting fails open when Redis raises an exception."""
# Arrange
key_prefix = 'email_resend'
user_id = 'test_user_id'
mock_redis.set = AsyncMock(side_effect=Exception('Redis connection error'))
with (
patch('server.utils.rate_limit_utils.sio') as mock_sio,
patch('server.utils.rate_limit_utils.logger') as mock_logger,
):
mock_sio.manager.redis = mock_redis
# Act
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=user_id
)
# Assert
mock_logger.warning.assert_called_once()
assert 'Error checking rate limit' in str(mock_logger.warning.call_args[0][0])
@pytest.mark.asyncio
async def test_rate_limit_custom_key_prefix(mock_request, mock_redis):
"""Test that different key prefixes create different rate limit keys."""
# Arrange
user_id = 'test_user_id'
key_prefix = 'password_reset'
with patch('server.utils.rate_limit_utils.sio') as mock_sio:
mock_sio.manager.redis = mock_redis
# Act
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=user_id
)
# Assert
mock_redis.set.assert_called_once_with(
f'{key_prefix}:{user_id}', 1, nx=True, ex=RATE_LIMIT_USER_SECONDS
)
@pytest.mark.asyncio
async def test_rate_limit_custom_rate_limit_seconds(mock_request, mock_redis):
"""Test that custom rate limit seconds are used correctly."""
# Arrange
user_id = 'test_user_id'
key_prefix = 'email_resend'
custom_user_seconds = 60
custom_ip_seconds = 180
with patch('server.utils.rate_limit_utils.sio') as mock_sio:
mock_sio.manager.redis = mock_redis
# Act
await check_rate_limit_by_user_id(
request=mock_request,
key_prefix=key_prefix,
user_id=user_id,
user_rate_limit_seconds=custom_user_seconds,
ip_rate_limit_seconds=custom_ip_seconds,
)
# Assert
mock_redis.set.assert_called_once_with(
f'{key_prefix}:{user_id}', 1, nx=True, ex=custom_user_seconds
)
@pytest.mark.asyncio
async def test_rate_limit_ip_with_unknown_client(mock_request, mock_redis):
"""Test that rate limiting handles missing client host gracefully."""
# Arrange
key_prefix = 'email_resend'
mock_request.client = None # No client information
with patch('server.utils.rate_limit_utils.sio') as mock_sio:
mock_sio.manager.redis = mock_redis
# Act
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=None
)
# Assert
mock_redis.set.assert_called_once_with(
f'{key_prefix}:ip:unknown', 1, nx=True, ex=RATE_LIMIT_IP_SECONDS
)
@pytest.mark.asyncio
async def test_rate_limit_different_users_have_separate_limits(
mock_request, mock_redis
):
"""Test that different user_ids have separate rate limit keys."""
# Arrange
key_prefix = 'email_resend'
user_id_1 = 'user_1'
user_id_2 = 'user_2'
with patch('server.utils.rate_limit_utils.sio') as mock_sio:
mock_sio.manager.redis = mock_redis
# Act
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=user_id_1
)
await check_rate_limit_by_user_id(
request=mock_request, key_prefix=key_prefix, user_id=user_id_2
)
# Assert
assert mock_redis.set.call_count == 2
# Extract call arguments properly
call_args_list = [
(call[0][0], call[0][1], call[1]['nx'], call[1]['ex'])
for call in mock_redis.set.call_args_list
]
assert (
f'{key_prefix}:{user_id_1}',
1,
True,
RATE_LIMIT_USER_SECONDS,
) in call_args_list
assert (
f'{key_prefix}:{user_id_2}',
1,
True,
RATE_LIMIT_USER_SECONDS,
) in call_args_list

View File

@@ -234,3 +234,53 @@ async def test_middleware_with_other_auth_error(middleware, mock_request):
assert 'set-cookie' in result.headers
# Logger should be called for non-NoCredentialsError
mock_logger.warning.assert_called_once()
@pytest.mark.asyncio
async def test_middleware_ignores_email_resend_path(
middleware, mock_request, mock_response
):
"""Test middleware ignores /api/email/resend path and doesn't require authentication."""
# Arrange
mock_request.cookies = {}
mock_request.url = MagicMock()
mock_request.url.hostname = 'localhost'
mock_request.url.path = '/api/email/resend'
mock_call_next = AsyncMock(return_value=mock_response)
# Act
result = await middleware(mock_request, mock_call_next)
# Assert
assert result == mock_response
mock_call_next.assert_called_once_with(mock_request)
# Should not raise NoCredentialsError even without auth cookie
@pytest.mark.asyncio
async def test_middleware_ignores_email_resend_path_no_tos_check(
middleware, mock_request, mock_response
):
"""Test middleware doesn't check TOS for /api/email/resend path."""
# Arrange
mock_request.cookies = {'keycloak_auth': 'test_cookie'}
mock_request.url = MagicMock()
mock_request.url.hostname = 'localhost'
mock_request.url.path = '/api/email/resend'
mock_call_next = AsyncMock(return_value=mock_response)
with (
patch('server.middleware.jwt.decode') as mock_decode,
patch('server.middleware.config') as mock_config,
):
# Even with accepted_tos=False, should not raise TosNotAcceptedError
mock_decode.return_value = {'accepted_tos': False}
mock_config.jwt_secret.get_secret_value.return_value = 'test_secret'
# Act
result = await middleware(mock_request, mock_call_next)
# Assert
assert result == mock_response
mock_call_next.assert_called_once_with(mock_request)
# Should not raise TosNotAcceptedError for this path

View File

@@ -249,6 +249,7 @@ async def test_keycloak_callback_email_not_verified(mock_request):
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
assert 'email_verification_required=true' in result.headers['location']
assert 'user_id=test_user_id' in result.headers['location']
mock_verify_email.assert_called_once_with(
request=mock_request, user_id='test_user_id', is_auth_flow=True
)
@@ -287,6 +288,7 @@ async def test_keycloak_callback_email_not_verified_missing_field(mock_request):
assert isinstance(result, RedirectResponse)
assert result.status_code == 302
assert 'email_verification_required=true' in result.headers['location']
assert 'user_id=test_user_id' in result.headers['location']
mock_verify_email.assert_called_once_with(
request=mock_request, user_id='test_user_id', is_auth_flow=True
)

View File

@@ -1,15 +1,35 @@
import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AxiosError } from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import EmailService from "#/api/email-service/email-service.api";
import { EmailVerificationModal } from "#/components/features/waitlist/email-verification-modal";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { renderWithProviders } from "../../../../test-utils";
describe("EmailVerificationModal", () => {
const mockOnClose = vi.fn();
let resendEmailVerificationSpy: ReturnType<typeof vi.spyOn>;
let displaySuccessToastSpy: ReturnType<typeof vi.spyOn>;
let displayErrorToastSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
resendEmailVerificationSpy = vi.spyOn(
EmailService,
"resendEmailVerification",
);
displaySuccessToastSpy = vi.spyOn(ToastHandlers, "displaySuccessToast");
displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should render the email verification message", () => {
// Arrange & Act
render(<EmailVerificationModal onClose={vi.fn()} />);
renderWithProviders(<EmailVerificationModal onClose={mockOnClose} />);
// Assert
expect(
@@ -19,10 +39,157 @@ describe("EmailVerificationModal", () => {
it("should render the TermsAndPrivacyNotice component", () => {
// Arrange & Act
render(<EmailVerificationModal onClose={vi.fn()} />);
renderWithProviders(<EmailVerificationModal onClose={mockOnClose} />);
// Assert
const termsSection = screen.getByTestId("terms-and-privacy-notice");
expect(termsSection).toBeInTheDocument();
});
it("should render resend verification button", () => {
// Arrange & Act
renderWithProviders(<EmailVerificationModal onClose={mockOnClose} />);
// Assert
expect(
screen.getByText("SETTINGS$RESEND_VERIFICATION"),
).toBeInTheDocument();
});
it("should call resendEmailVerification", async () => {
// Arrange
const userId = "test_user_id";
resendEmailVerificationSpy.mockResolvedValue({
message: "Email verification message sent",
});
renderWithProviders(
<EmailVerificationModal onClose={mockOnClose} userId={userId} />,
);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(resendEmailVerificationSpy).toHaveBeenCalledWith(userId, true);
});
});
it("should display success toast when resend succeeds", async () => {
// Arrange
resendEmailVerificationSpy.mockResolvedValue({
message: "Email verification message sent",
});
renderWithProviders(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
"SETTINGS$VERIFICATION_EMAIL_SENT",
);
});
});
it("should display rate limit error message when receiving 429 status", async () => {
// Arrange
const rateLimitError = new AxiosError("Rate limit exceeded");
rateLimitError.response = {
status: 429,
statusText: "Too Many Requests",
data: {
detail: "Too many requests. Please wait 2 minutes before trying again.",
},
headers: {},
config: {} as any,
};
resendEmailVerificationSpy.mockRejectedValue(rateLimitError);
renderWithProviders(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(displayErrorToastSpy).toHaveBeenCalledWith(
"Too many requests. Please wait 2 minutes before trying again.",
);
});
});
it("should display generic error message when receiving non-429 error", async () => {
// Arrange
const genericError = new AxiosError("Network error");
genericError.response = {
status: 500,
statusText: "Internal Server Error",
data: { error: "Internal server error" },
headers: {},
config: {} as any,
};
resendEmailVerificationSpy.mockRejectedValue(genericError);
renderWithProviders(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(displayErrorToastSpy).toHaveBeenCalledWith(
"SETTINGS$FAILED_TO_RESEND_VERIFICATION",
);
});
});
it("should disable button and show sending text while request is pending", async () => {
// Arrange
let resolvePromise: (value: { message: string }) => void;
const pendingPromise = new Promise<{ message: string }>((resolve) => {
resolvePromise = resolve;
});
resendEmailVerificationSpy.mockReturnValue(pendingPromise);
renderWithProviders(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
const sendingButton = screen.getByText("SETTINGS$SENDING");
expect(sendingButton).toBeInTheDocument();
expect(sendingButton).toBeDisabled();
});
// Cleanup
resolvePromise!({ message: "Email verification message sent" });
});
it("should re-enable button after request completes", async () => {
// Arrange
resendEmailVerificationSpy.mockResolvedValue({
message: "Email verification message sent",
});
renderWithProviders(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(resendEmailVerificationSpy).toHaveBeenCalled();
});
await waitFor(() => {
const enabledButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
expect(enabledButton).toBeInTheDocument();
expect(enabledButton).not.toBeDisabled();
});
});
});

View File

@@ -0,0 +1,33 @@
import { openHands } from "../open-hands-axios";
/**
* Email Service API - Handles all email-related API endpoints
*/
class EmailService {
/**
* Resend email verification to the user's registered email address
* @param userId - Optional user ID to send verification email for
* @param isAuthFlow - Whether this is part of the authentication flow
* @returns The response message indicating the email was sent
*/
static async resendEmailVerification(
userId?: string | null,
isAuthFlow?: boolean,
): Promise<{ message: string }> {
const body: { user_id?: string; is_auth_flow?: boolean } = {};
if (userId) {
body.user_id = userId;
}
if (isAuthFlow !== undefined) {
body.is_auth_flow = isAuthFlow;
}
const { data } = await openHands.put<{ message: string }>(
"/api/email/resend",
body,
{ withCredentials: true },
);
return data;
}
}
export default EmailService;

View File

@@ -4,15 +4,21 @@ 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";
import { BrandButton } from "../settings/brand-button";
import { useResendEmailVerification } from "#/hooks/mutation/use-resend-email-verification";
interface EmailVerificationModalProps {
onClose: () => void;
userId?: string | null;
}
export function EmailVerificationModal({
onClose,
userId,
}: EmailVerificationModalProps) {
const { t } = useTranslation();
const { mutate: resendEmailVerification, isPending: isResending } =
useResendEmailVerification();
return (
<ModalBackdrop onClose={onClose}>
@@ -24,6 +30,22 @@ export function EmailVerificationModal({
</h1>
</div>
<div className="flex flex-col gap-3 w-full mt-4">
<BrandButton
type="button"
variant="primary"
onClick={() =>
resendEmailVerification({ userId, isAuthFlow: true })
}
isDisabled={isResending}
className="w-full font-semibold"
>
{isResending
? t(I18nKey.SETTINGS$SENDING)
: t(I18nKey.SETTINGS$RESEND_VERIFICATION)}
</BrandButton>
</div>
<TermsAndPrivacyNotice />
</ModalBody>
</ModalBackdrop>

View File

@@ -0,0 +1,46 @@
import { AxiosError } from "axios";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import EmailService from "#/api/email-service/email-service.api";
import {
displaySuccessToast,
displayErrorToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export const useResendEmailVerification = () => {
const { t } = useTranslation();
return useMutation({
mutationFn: ({
userId,
isAuthFlow,
}: {
userId?: string | null;
isAuthFlow?: boolean;
}) => EmailService.resendEmailVerification(userId, isAuthFlow),
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$VERIFICATION_EMAIL_SENT));
},
onError: (error: AxiosError) => {
// Check if it's a rate limit error (429)
if (error.response?.status === 429) {
// FastAPI returns errors in { detail: "..." } format
const errorData = error.response.data as
| { detail?: string }
| undefined;
const rateLimitMessage =
errorData?.detail ||
retrieveAxiosErrorMessage(error) ||
t(I18nKey.SETTINGS$FAILED_TO_RESEND_VERIFICATION);
displayErrorToast(rateLimitMessage);
} else {
// For other errors, show the generic error message
displayErrorToast(t(I18nKey.SETTINGS$FAILED_TO_RESEND_VERIFICATION));
}
},
});
};

View File

@@ -12,6 +12,7 @@ import { useSearchParams } from "react-router";
* - emailVerified: boolean state for email verification status
* - setEmailVerified: function to control email verification status
* - hasDuplicatedEmail: boolean state for duplicate email error status
* - userId: string | null for the user ID from the redirect URL
*/
export function useEmailVerification() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -19,6 +20,7 @@ export function useEmailVerification() {
React.useState(false);
const [emailVerified, setEmailVerified] = React.useState(false);
const [hasDuplicatedEmail, setHasDuplicatedEmail] = React.useState(false);
const [userId, setUserId] = React.useState<string | null>(null);
// Check for email verification query parameters
React.useEffect(() => {
@@ -27,6 +29,7 @@ export function useEmailVerification() {
);
const emailVerifiedParam = searchParams.get("email_verified");
const duplicatedEmailParam = searchParams.get("duplicated_email");
const userIdParam = searchParams.get("user_id");
let shouldUpdate = false;
if (emailVerificationRequired === "true") {
@@ -47,6 +50,12 @@ export function useEmailVerification() {
shouldUpdate = true;
}
if (userIdParam) {
setUserId(userIdParam);
searchParams.delete("user_id");
shouldUpdate = true;
}
// Clean up the URL by removing parameters if any were found
if (shouldUpdate) {
setSearchParams(searchParams, { replace: true });
@@ -59,5 +68,6 @@ export function useEmailVerification() {
emailVerified,
setEmailVerified,
hasDuplicatedEmail,
userId,
};
}

View File

@@ -98,6 +98,7 @@ export default function MainApp() {
setEmailVerificationModalOpen,
emailVerified,
hasDuplicatedEmail,
userId,
} = useEmailVerification();
// Auto-login if login method is stored in local storage
@@ -254,6 +255,7 @@ export default function MainApp() {
onClose={() => {
setEmailVerificationModalOpen(false);
}}
userId={userId}
/>
)}
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { useResendEmailVerification } from "#/hooks/mutation/use-resend-email-verification";
// Email validation regex pattern
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
@@ -115,11 +116,14 @@ function UserSettingsScreen() {
const [email, setEmail] = useState("");
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const [isEmailValid, setIsEmailValid] = useState(true);
const queryClient = useQueryClient();
const pollingIntervalRef = useRef<number | null>(null);
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
const {
mutate: resendEmailVerification,
isPending: isResendingVerification,
} = useResendEmailVerification();
useEffect(() => {
if (settings?.email) {
@@ -185,18 +189,8 @@ function UserSettingsScreen() {
}
};
const handleResendVerification = async () => {
try {
setIsResendingVerification(true);
await openHands.put("/api/email/verify", {}, { withCredentials: true });
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$VERIFICATION_EMAIL_SENT"));
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_RESEND_VERIFICATION"), error);
} finally {
setIsResendingVerification(false);
}
const handleResendVerification = () => {
resendEmailVerification({});
};
const isEmailChanged = email !== originalEmail;