feat: add button to authentication modal to resend verification email (#12179)

This commit is contained in:
Hiep Le
2025-12-30 02:12:14 +07:00
committed by GitHub
parent d628e1f20a
commit 8ee1394e8c
17 changed files with 1073 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,41 @@ 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)
# Use 30 seconds for user-based rate limiting to match frontend cooldown
await check_rate_limit_by_user_id(
request=request,
key_prefix='email_resend',
user_id=user_id,
user_rate_limit_seconds=30,
ip_rate_limit_seconds=60, # 1 minute for IP-based limiting (more lenient)
)
# 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 +165,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,83 @@
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',
},
)
# Format error message based on duration
if rate_limit_seconds < 60:
wait_message = f'{rate_limit_seconds} seconds'
elif rate_limit_seconds % 60 == 0:
wait_message = f'{rate_limit_seconds // 60} minute{"s" if rate_limit_seconds // 60 != 1 else ""}'
else:
minutes = rate_limit_seconds // 60
seconds = rate_limit_seconds % 60
wait_message = f'{minutes} minute{"s" if minutes != 1 else ""} and {seconds} second{"s" if seconds != 1 else ""}'
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f'Too many requests. Please wait {wait_message} 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,208 @@ 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,
user_rate_limit_seconds=30,
ip_rate_limit_seconds=60,
)
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,
user_rate_limit_seconds=30,
ip_rate_limit_seconds=60,
)
@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,
user_rate_limit_seconds=30,
ip_rate_limit_seconds=60,
)

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,34 @@
import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach } from "vitest";
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
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, createAxiosError } from "../../../../test-utils";
describe("EmailVerificationModal", () => {
const mockOnClose = vi.fn();
const resendEmailVerificationSpy = vi.spyOn(
emailService,
"resendEmailVerification",
);
const displaySuccessToastSpy = vi.spyOn(ToastHandlers, "displaySuccessToast");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
const renderWithRouter = (ui: React.ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the email verification message", () => {
// Arrange & Act
render(<EmailVerificationModal onClose={vi.fn()} />);
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Assert
expect(
@@ -19,10 +38,150 @@ describe("EmailVerificationModal", () => {
it("should render the TermsAndPrivacyNotice component", () => {
// Arrange & Act
render(<EmailVerificationModal onClose={vi.fn()} />);
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Assert
const termsSection = screen.getByTestId("terms-and-privacy-notice");
expect(termsSection).toBeInTheDocument();
});
it("should render resend verification button", () => {
// Arrange & Act
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Assert
expect(
screen.getByText("SETTINGS$RESEND_VERIFICATION"),
).toBeInTheDocument();
});
it("should call resendEmailVerification when the button is clicked", async () => {
// Arrange
const userId = "test_user_id";
resendEmailVerificationSpy.mockResolvedValue({
message: "Email verification message sent",
});
renderWithRouter(
<EmailVerificationModal onClose={mockOnClose} userId={userId} />,
);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(resendEmailVerificationSpy).toHaveBeenCalledWith({
userId,
isAuthFlow: true,
});
});
});
it("should display success toast when resend succeeds", async () => {
// Arrange
resendEmailVerificationSpy.mockResolvedValue({
message: "Email verification message sent",
});
renderWithRouter(<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 = createAxiosError(429, "Too Many Requests", {
detail: "Too many requests. Please wait 2 minutes before trying again.",
});
resendEmailVerificationSpy.mockRejectedValue(rateLimitError);
renderWithRouter(<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 = createAxiosError(500, "Internal Server Error", {
error: "Internal server error",
});
resendEmailVerificationSpy.mockRejectedValue(genericError);
renderWithRouter(<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);
renderWithRouter(<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",
});
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(resendEmailVerificationSpy).toHaveBeenCalled();
});
// After successful send, the button will be disabled due to cooldown
// So we just verify the mutation was called successfully
await waitFor(() => {
const button = screen.getByRole("button");
expect(button).toBeDisabled(); // Button is disabled during cooldown
expect(button).toHaveTextContent(/SETTINGS\$RESEND_VERIFICATION/);
});
});
});

View File

@@ -0,0 +1,35 @@
import { openHands } from "../open-hands-axios";
import {
ResendEmailVerificationParams,
ResendEmailVerificationResponse,
} from "./email.types";
/**
* Email Service API - Handles all email-related API endpoints
*/
export const 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
*/
resendEmailVerification: async ({
userId,
isAuthFlow,
}: ResendEmailVerificationParams): Promise<ResendEmailVerificationResponse> => {
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<ResendEmailVerificationResponse>(
"/api/email/resend",
body,
{ withCredentials: true },
);
return data;
},
};

View File

@@ -0,0 +1,8 @@
export interface ResendEmailVerificationParams {
userId?: string | null;
isAuthFlow?: boolean;
}
export interface ResendEmailVerificationResponse {
message: string;
}

View File

@@ -4,15 +4,34 @@ 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 { useEmailVerification } from "#/hooks/use-email-verification";
interface EmailVerificationModalProps {
onClose: () => void;
userId?: string | null;
}
export function EmailVerificationModal({
onClose,
userId,
}: EmailVerificationModalProps) {
const { t } = useTranslation();
const {
resendEmailVerification,
isResendingVerification: isResending,
isCooldownActive,
formattedCooldownTime,
} = useEmailVerification();
let resendButtonLabel: string;
if (isResending) {
resendButtonLabel = t(I18nKey.SETTINGS$SENDING);
} else if (isCooldownActive) {
resendButtonLabel = `${t(I18nKey.SETTINGS$RESEND_VERIFICATION)} (${formattedCooldownTime})`;
} else {
resendButtonLabel = t(I18nKey.SETTINGS$RESEND_VERIFICATION);
}
return (
<ModalBackdrop onClose={onClose}>
@@ -24,6 +43,20 @@ 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 || isCooldownActive}
className="w-full font-semibold"
>
{resendButtonLabel}
</BrandButton>
</div>
<TermsAndPrivacyNotice />
</ModalBody>
</ModalBackdrop>

View File

@@ -0,0 +1,49 @@
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
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";
import { ResendEmailVerificationParams } from "#/api/email-service/email.types";
interface UseResendEmailVerificationOptions {
onSuccess?: () => void;
}
export const useResendEmailVerification = (
options?: UseResendEmailVerificationOptions,
) => {
const { t } = useTranslation();
return useMutation({
mutationFn: (params: ResendEmailVerificationParams) =>
emailService.resendEmailVerification(params),
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$VERIFICATION_EMAIL_SENT));
options?.onSuccess?.();
},
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

@@ -1,10 +1,12 @@
import React from "react";
import { useSearchParams } from "react-router";
import { useResendEmailVerification } from "#/hooks/mutation/use-resend-email-verification";
/**
* 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.
* Also provides functionality to resend email verification.
*
* @returns An object containing:
* - emailVerificationModalOpen: boolean state for modal visibility
@@ -12,6 +14,12 @@ 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
* - resendEmailVerification: function to resend verification email
* - isResendingVerification: boolean indicating if resend is in progress
* - isCooldownActive: boolean indicating if cooldown is currently active
* - cooldownRemaining: number of milliseconds remaining in cooldown
* - formattedCooldownTime: string formatted as "M:SS" for display
*/
export function useEmailVerification() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -19,6 +27,26 @@ 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);
const [lastSentTimestamp, setLastSentTimestamp] = React.useState<
number | null
>(null);
const [cooldownRemaining, setCooldownRemaining] = React.useState<number>(0);
const COOLDOWN_DURATION_MS = 30 * 1000; // 30 seconds
const formatCooldownTime = (ms: number): string => {
const seconds = Math.ceil(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
};
const resendEmailVerificationMutation = useResendEmailVerification({
onSuccess: () => {
setLastSentTimestamp(Date.now());
},
});
// Check for email verification query parameters
React.useEffect(() => {
@@ -27,6 +55,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,17 +76,61 @@ 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 });
}
}, [searchParams, setSearchParams]);
// Update cooldown remaining time
React.useEffect(() => {
if (lastSentTimestamp === null) {
setCooldownRemaining(0);
return undefined;
}
let timeoutId: NodeJS.Timeout | null = null;
const updateCooldown = () => {
const elapsed = Date.now() - lastSentTimestamp!;
const remaining = Math.max(0, COOLDOWN_DURATION_MS - elapsed);
setCooldownRemaining(remaining);
if (remaining > 0) {
// Update every second while cooldown is active
timeoutId = setTimeout(updateCooldown, 1000);
}
};
updateCooldown();
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [lastSentTimestamp, COOLDOWN_DURATION_MS]);
const isCooldownActive = cooldownRemaining > 0;
const formattedCooldownTime = formatCooldownTime(cooldownRemaining);
return {
emailVerificationModalOpen,
setEmailVerificationModalOpen,
emailVerified,
setEmailVerified,
hasDuplicatedEmail,
userId,
resendEmailVerification: resendEmailVerificationMutation.mutate,
isResendingVerification: resendEmailVerificationMutation.isPending,
isCooldownActive,
cooldownRemaining,
formattedCooldownTime,
};
}

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 { useEmailVerification } from "#/hooks/use-email-verification";
// Email validation regex pattern
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
@@ -115,11 +116,12 @@ 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 { resendEmailVerification, isResendingVerification } =
useEmailVerification();
useEffect(() => {
if (settings?.email) {
@@ -185,18 +187,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;

View File

@@ -74,3 +74,23 @@ export const createAxiosNotFoundErrorObject = () =>
config: {},
},
);
export const createAxiosError = (
status: number,
statusText: string,
data: unknown,
) =>
new AxiosError(
`Request failed with status code ${status}`,
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status,
statusText,
data,
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);