mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 06:23:59 -05:00
feat: add button to authentication modal to resend verification email (#12179)
This commit is contained in:
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
83
enterprise/server/utils/rate_limit_utils.py
Normal file
83
enterprise/server/utils/rate_limit_utils.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
290
enterprise/tests/unit/server/test_rate_limit_utils.py
Normal file
290
enterprise/tests/unit/server/test_rate_limit_utils.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
35
frontend/src/api/email-service/email-service.api.ts
Normal file
35
frontend/src/api/email-service/email-service.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
8
frontend/src/api/email-service/email.types.ts
Normal file
8
frontend/src/api/email-service/email.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface ResendEmailVerificationParams {
|
||||
userId?: string | null;
|
||||
isAuthFlow?: boolean;
|
||||
}
|
||||
|
||||
export interface ResendEmailVerificationResponse {
|
||||
message: string;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
49
frontend/src/hooks/mutation/use-resend-email-verification.ts
Normal file
49
frontend/src/hooks/mutation/use-resend-email-verification.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user