mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
feat: require email verification for new signups (#12123)
This commit is contained in:
parent
dc99c7b62e
commit
e2b2aa52cd
@ -202,6 +202,18 @@ async def keycloak_callback(
|
|||||||
extra={'user_id': user_id, 'email': email},
|
extra={'user_id': user_id, 'email': email},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check email verification status
|
||||||
|
email_verified = user_info.get('email_verified', False)
|
||||||
|
if not email_verified:
|
||||||
|
# Send verification email
|
||||||
|
# Import locally to avoid circular import with email.py
|
||||||
|
from server.routes.email import verify_email
|
||||||
|
|
||||||
|
await verify_email(request=request, user_id=user_id, is_auth_flow=True)
|
||||||
|
redirect_url = f'{request.base_url}?email_verification_required=true'
|
||||||
|
response = RedirectResponse(redirect_url, status_code=302)
|
||||||
|
return response
|
||||||
|
|
||||||
# default to github IDP for now.
|
# default to github IDP for now.
|
||||||
# TODO: remove default once Keycloak is updated universally with the new attribute.
|
# TODO: remove default once Keycloak is updated universally with the new attribute.
|
||||||
idp: str = user_info.get('identity_provider', ProviderType.GITHUB.value)
|
idp: str = user_info.get('identity_provider', ProviderType.GITHUB.value)
|
||||||
|
|||||||
@ -74,7 +74,7 @@ async def update_email(
|
|||||||
accepted_tos=user_auth.accepted_tos,
|
accepted_tos=user_auth.accepted_tos,
|
||||||
)
|
)
|
||||||
|
|
||||||
await _verify_email(request=request, user_id=user_id)
|
await verify_email(request=request, user_id=user_id)
|
||||||
|
|
||||||
logger.info(f'Updating email address for {user_id} to {email}')
|
logger.info(f'Updating email address for {user_id} to {email}')
|
||||||
return response
|
return response
|
||||||
@ -91,8 +91,10 @@ async def update_email(
|
|||||||
|
|
||||||
|
|
||||||
@api_router.put('/verify')
|
@api_router.put('/verify')
|
||||||
async def verify_email(request: Request, user_id: str = Depends(get_user_id)):
|
async def resend_email_verification(
|
||||||
await _verify_email(request=request, user_id=user_id)
|
request: Request, user_id: str = Depends(get_user_id)
|
||||||
|
):
|
||||||
|
await verify_email(request=request, user_id=user_id)
|
||||||
|
|
||||||
logger.info(f'Resending verification email for {user_id}')
|
logger.info(f'Resending verification email for {user_id}')
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -124,10 +126,14 @@ async def verified_email(request: Request):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
async def _verify_email(request: Request, user_id: str):
|
async def verify_email(request: Request, user_id: str, is_auth_flow: bool = False):
|
||||||
keycloak_admin = get_keycloak_admin()
|
keycloak_admin = get_keycloak_admin()
|
||||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||||
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
|
redirect_uri = (
|
||||||
|
f'{scheme}://{request.url.netloc}?email_verified=true'
|
||||||
|
if is_auth_flow
|
||||||
|
else f'{scheme}://{request.url.netloc}/api/email/verified'
|
||||||
|
)
|
||||||
logger.info(f'Redirect URI: {redirect_uri}')
|
logger.info(f'Redirect URI: {redirect_uri}')
|
||||||
await keycloak_admin.a_send_verify_email(
|
await keycloak_admin.a_send_verify_email(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
151
enterprise/tests/unit/server/routes/test_email_routes.py
Normal file
151
enterprise/tests/unit/server/routes/test_email_routes.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import SecretStr
|
||||||
|
from server.auth.saas_user_auth import SaasUserAuth
|
||||||
|
from server.routes.email import verified_email, verify_email
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_request():
|
||||||
|
"""Create a mock request object."""
|
||||||
|
request = MagicMock(spec=Request)
|
||||||
|
request.url = MagicMock()
|
||||||
|
request.url.hostname = 'localhost'
|
||||||
|
request.url.netloc = 'localhost:8000'
|
||||||
|
request.url.path = '/api/email/verified'
|
||||||
|
request.base_url = 'http://localhost:8000/'
|
||||||
|
request.headers = {}
|
||||||
|
request.cookies = {}
|
||||||
|
request.query_params = MagicMock()
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_user_auth():
|
||||||
|
"""Create a mock SaasUserAuth object."""
|
||||||
|
auth = MagicMock(spec=SaasUserAuth)
|
||||||
|
auth.access_token = SecretStr('test_access_token')
|
||||||
|
auth.refresh_token = SecretStr('test_refresh_token')
|
||||||
|
auth.email = 'test@example.com'
|
||||||
|
auth.email_verified = False
|
||||||
|
auth.accepted_tos = True
|
||||||
|
auth.refresh = AsyncMock()
|
||||||
|
return auth
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_email_default_behavior(mock_request):
|
||||||
|
"""Test verify_email with default is_auth_flow=False."""
|
||||||
|
# Arrange
|
||||||
|
user_id = 'test_user_id'
|
||||||
|
mock_keycloak_admin = AsyncMock()
|
||||||
|
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch(
|
||||||
|
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||||
|
):
|
||||||
|
await verify_email(request=mock_request, user_id=user_id)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_keycloak_admin.a_send_verify_email.assert_called_once()
|
||||||
|
call_args = mock_keycloak_admin.a_send_verify_email.call_args
|
||||||
|
assert call_args.kwargs['user_id'] == user_id
|
||||||
|
assert (
|
||||||
|
call_args.kwargs['redirect_uri'] == 'http://localhost:8000/api/email/verified'
|
||||||
|
)
|
||||||
|
assert 'client_id' in call_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_email_with_auth_flow(mock_request):
|
||||||
|
"""Test verify_email with is_auth_flow=True."""
|
||||||
|
# Arrange
|
||||||
|
user_id = 'test_user_id'
|
||||||
|
mock_keycloak_admin = AsyncMock()
|
||||||
|
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch(
|
||||||
|
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||||
|
):
|
||||||
|
await verify_email(request=mock_request, user_id=user_id, is_auth_flow=True)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
mock_keycloak_admin.a_send_verify_email.assert_called_once()
|
||||||
|
call_args = mock_keycloak_admin.a_send_verify_email.call_args
|
||||||
|
assert call_args.kwargs['user_id'] == user_id
|
||||||
|
assert (
|
||||||
|
call_args.kwargs['redirect_uri'] == 'http://localhost:8000?email_verified=true'
|
||||||
|
)
|
||||||
|
assert 'client_id' in call_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_email_https_scheme(mock_request):
|
||||||
|
"""Test verify_email uses https scheme for non-localhost hosts."""
|
||||||
|
# Arrange
|
||||||
|
user_id = 'test_user_id'
|
||||||
|
mock_request.url.hostname = 'example.com'
|
||||||
|
mock_request.url.netloc = 'example.com'
|
||||||
|
mock_keycloak_admin = AsyncMock()
|
||||||
|
mock_keycloak_admin.a_send_verify_email = AsyncMock()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch(
|
||||||
|
'server.routes.email.get_keycloak_admin', return_value=mock_keycloak_admin
|
||||||
|
):
|
||||||
|
await verify_email(request=mock_request, user_id=user_id, is_auth_flow=True)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
call_args = mock_keycloak_admin.a_send_verify_email.call_args
|
||||||
|
assert call_args.kwargs['redirect_uri'].startswith('https://')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verified_email_default_redirect(mock_request, mock_user_auth):
|
||||||
|
"""Test verified_email redirects to /settings/user by default."""
|
||||||
|
# Arrange
|
||||||
|
mock_request.query_params.get.return_value = None
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with (
|
||||||
|
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
|
||||||
|
patch('server.routes.email.set_response_cookie') as mock_set_cookie,
|
||||||
|
):
|
||||||
|
result = await verified_email(mock_request)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(result, RedirectResponse)
|
||||||
|
assert result.status_code == 302
|
||||||
|
assert result.headers['location'] == 'http://localhost:8000/settings/user'
|
||||||
|
mock_user_auth.refresh.assert_called_once()
|
||||||
|
mock_set_cookie.assert_called_once()
|
||||||
|
assert mock_user_auth.email_verified is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verified_email_https_scheme(mock_request, mock_user_auth):
|
||||||
|
"""Test verified_email uses https scheme for non-localhost hosts."""
|
||||||
|
# Arrange
|
||||||
|
mock_request.url.hostname = 'example.com'
|
||||||
|
mock_request.url.netloc = 'example.com'
|
||||||
|
mock_request.query_params.get.return_value = None
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with (
|
||||||
|
patch('server.routes.email.get_user_auth', return_value=mock_user_auth),
|
||||||
|
patch('server.routes.email.set_response_cookie') as mock_set_cookie,
|
||||||
|
):
|
||||||
|
result = await verified_email(mock_request)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(result, RedirectResponse)
|
||||||
|
assert result.headers['location'].startswith('https://')
|
||||||
|
mock_set_cookie.assert_called_once()
|
||||||
|
# Verify secure flag is True for https
|
||||||
|
call_kwargs = mock_set_cookie.call_args.kwargs
|
||||||
|
assert call_kwargs['secure'] is True
|
||||||
@ -136,6 +136,7 @@ async def test_keycloak_callback_user_not_allowed(mock_request):
|
|||||||
'sub': 'test_user_id',
|
'sub': 'test_user_id',
|
||||||
'preferred_username': 'test_user',
|
'preferred_username': 'test_user',
|
||||||
'identity_provider': 'github',
|
'identity_provider': 'github',
|
||||||
|
'email_verified': True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||||
@ -184,6 +185,7 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request):
|
|||||||
'sub': 'test_user_id',
|
'sub': 'test_user_id',
|
||||||
'preferred_username': 'test_user',
|
'preferred_username': 'test_user',
|
||||||
'identity_provider': 'github',
|
'identity_provider': 'github',
|
||||||
|
'email_verified': True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||||
@ -214,6 +216,82 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request):
|
|||||||
mock_posthog.set.assert_called_once()
|
mock_posthog.set.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_keycloak_callback_email_not_verified(mock_request):
|
||||||
|
"""Test keycloak_callback when email is not verified."""
|
||||||
|
# Arrange
|
||||||
|
mock_verify_email = AsyncMock()
|
||||||
|
with (
|
||||||
|
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||||
|
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||||
|
patch('server.routes.email.verify_email', mock_verify_email),
|
||||||
|
):
|
||||||
|
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||||
|
return_value=('test_access_token', 'test_refresh_token')
|
||||||
|
)
|
||||||
|
mock_token_manager.get_user_info = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'sub': 'test_user_id',
|
||||||
|
'preferred_username': 'test_user',
|
||||||
|
'identity_provider': 'github',
|
||||||
|
'email_verified': False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||||
|
mock_verifier.is_active.return_value = False
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = await keycloak_callback(
|
||||||
|
code='test_code', state='test_state', request=mock_request
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(result, RedirectResponse)
|
||||||
|
assert result.status_code == 302
|
||||||
|
assert 'email_verification_required=true' in result.headers['location']
|
||||||
|
mock_verify_email.assert_called_once_with(
|
||||||
|
request=mock_request, user_id='test_user_id', is_auth_flow=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_keycloak_callback_email_not_verified_missing_field(mock_request):
|
||||||
|
"""Test keycloak_callback when email_verified field is missing (defaults to False)."""
|
||||||
|
# Arrange
|
||||||
|
mock_verify_email = AsyncMock()
|
||||||
|
with (
|
||||||
|
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||||
|
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||||
|
patch('server.routes.email.verify_email', mock_verify_email),
|
||||||
|
):
|
||||||
|
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||||
|
return_value=('test_access_token', 'test_refresh_token')
|
||||||
|
)
|
||||||
|
mock_token_manager.get_user_info = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'sub': 'test_user_id',
|
||||||
|
'preferred_username': 'test_user',
|
||||||
|
'identity_provider': 'github',
|
||||||
|
# email_verified field is missing
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||||
|
mock_verifier.is_active.return_value = False
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = await keycloak_callback(
|
||||||
|
code='test_code', state='test_state', request=mock_request
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(result, RedirectResponse)
|
||||||
|
assert result.status_code == 302
|
||||||
|
assert 'email_verification_required=true' in result.headers['location']
|
||||||
|
mock_verify_email.assert_called_once_with(
|
||||||
|
request=mock_request, user_id='test_user_id', is_auth_flow=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_keycloak_callback_success_without_offline_token(mock_request):
|
async def test_keycloak_callback_success_without_offline_token(mock_request):
|
||||||
"""Test successful keycloak_callback without valid offline token."""
|
"""Test successful keycloak_callback without valid offline token."""
|
||||||
@ -248,6 +326,7 @@ async def test_keycloak_callback_success_without_offline_token(mock_request):
|
|||||||
'sub': 'test_user_id',
|
'sub': 'test_user_id',
|
||||||
'preferred_username': 'test_user',
|
'preferred_username': 'test_user',
|
||||||
'identity_provider': 'github',
|
'identity_provider': 'github',
|
||||||
|
'email_verified': True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||||
@ -513,6 +592,7 @@ async def test_keycloak_callback_allowed_email_domain(mock_request):
|
|||||||
'preferred_username': 'test_user',
|
'preferred_username': 'test_user',
|
||||||
'email': 'user@example.com',
|
'email': 'user@example.com',
|
||||||
'identity_provider': 'github',
|
'identity_provider': 'github',
|
||||||
|
'email_verified': True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||||
@ -566,6 +646,7 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request):
|
|||||||
'preferred_username': 'test_user',
|
'preferred_username': 'test_user',
|
||||||
'email': 'user@colsch.us',
|
'email': 'user@colsch.us',
|
||||||
'identity_provider': 'github',
|
'identity_provider': 'github',
|
||||||
|
'email_verified': True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||||
@ -615,6 +696,7 @@ async def test_keycloak_callback_missing_email(mock_request):
|
|||||||
'sub': 'test_user_id',
|
'sub': 'test_user_id',
|
||||||
'preferred_username': 'test_user',
|
'preferred_username': 'test_user',
|
||||||
'identity_provider': 'github',
|
'identity_provider': 'github',
|
||||||
|
'email_verified': True,
|
||||||
# No email field
|
# No email field
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -733,6 +815,7 @@ async def test_keycloak_callback_duplicate_check_exception(mock_request):
|
|||||||
'preferred_username': 'test_user',
|
'preferred_username': 'test_user',
|
||||||
'email': 'joe+test@example.com',
|
'email': 'joe+test@example.com',
|
||||||
'identity_provider': 'github',
|
'identity_provider': 'github',
|
||||||
|
'email_verified': True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||||
@ -782,6 +865,7 @@ async def test_keycloak_callback_no_duplicate_email(mock_request):
|
|||||||
'preferred_username': 'test_user',
|
'preferred_username': 'test_user',
|
||||||
'email': 'joe+test@example.com',
|
'email': 'joe+test@example.com',
|
||||||
'identity_provider': 'github',
|
'identity_provider': 'github',
|
||||||
|
'email_verified': True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_token_manager.check_duplicate_base_email = AsyncMock(return_value=False)
|
mock_token_manager.check_duplicate_base_email = AsyncMock(return_value=False)
|
||||||
@ -833,6 +917,7 @@ async def test_keycloak_callback_no_email_in_user_info(mock_request):
|
|||||||
'preferred_username': 'test_user',
|
'preferred_username': 'test_user',
|
||||||
# No email field
|
# No email field
|
||||||
'identity_provider': 'github',
|
'identity_provider': 'github',
|
||||||
|
'email_verified': True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||||
|
|||||||
@ -77,7 +77,7 @@ describe("AuthModal", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Find the terms of service section using data-testid
|
// Find the terms of service section using data-testid
|
||||||
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
|
const termsSection = screen.getByTestId("terms-and-privacy-notice");
|
||||||
expect(termsSection).toBeInTheDocument();
|
expect(termsSection).toBeInTheDocument();
|
||||||
|
|
||||||
// Check that all text content is present in the paragraph
|
// Check that all text content is present in the paragraph
|
||||||
@ -114,6 +114,38 @@ describe("AuthModal", () => {
|
|||||||
expect(termsSection).toContainElement(privacyLink);
|
expect(termsSection).toContainElement(privacyLink);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should display email verified message when emailVerified prop is true", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AuthModal
|
||||||
|
githubAuthUrl="mock-url"
|
||||||
|
appMode="saas"
|
||||||
|
emailVerified={true}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not display email verified message when emailVerified prop is false", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AuthModal
|
||||||
|
githubAuthUrl="mock-url"
|
||||||
|
appMode="saas"
|
||||||
|
emailVerified={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("should open Terms of Service link in new tab", () => {
|
it("should open Terms of Service link in new tab", () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@ -142,12 +174,17 @@ describe("AuthModal", () => {
|
|||||||
|
|
||||||
describe("Duplicate email error message", () => {
|
describe("Duplicate email error message", () => {
|
||||||
const renderAuthModalWithRouter = (initialEntries: string[]) => {
|
const renderAuthModalWithRouter = (initialEntries: string[]) => {
|
||||||
|
const hasDuplicatedEmail = initialEntries.includes(
|
||||||
|
"/?duplicated_email=true",
|
||||||
|
);
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={initialEntries}>
|
<MemoryRouter initialEntries={initialEntries}>
|
||||||
<AuthModal
|
<AuthModal
|
||||||
githubAuthUrl="mock-url"
|
githubAuthUrl="mock-url"
|
||||||
appMode="saas"
|
appMode="saas"
|
||||||
providersConfigured={["github"]}
|
providersConfigured={["github"]}
|
||||||
|
hasDuplicatedEmail={hasDuplicatedEmail}
|
||||||
/>
|
/>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { it, describe, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { EmailVerificationModal } from "#/components/features/waitlist/email-verification-modal";
|
||||||
|
|
||||||
|
describe("EmailVerificationModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the email verification message", () => {
|
||||||
|
// Arrange & Act
|
||||||
|
render(<EmailVerificationModal onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(
|
||||||
|
screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the TermsAndPrivacyNotice component", () => {
|
||||||
|
// Arrange & Act
|
||||||
|
render(<EmailVerificationModal onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const termsSection = screen.getByTestId("terms-and-privacy-notice");
|
||||||
|
expect(termsSection).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { it, describe, expect } from "vitest";
|
||||||
|
import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice";
|
||||||
|
|
||||||
|
describe("TermsAndPrivacyNotice", () => {
|
||||||
|
it("should render Terms of Service and Privacy Policy links", () => {
|
||||||
|
// Arrange & Act
|
||||||
|
render(<TermsAndPrivacyNotice />);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const termsSection = screen.getByTestId("terms-and-privacy-notice");
|
||||||
|
expect(termsSection).toBeInTheDocument();
|
||||||
|
|
||||||
|
const tosLink = screen.getByRole("link", {
|
||||||
|
name: "COMMON$TERMS_OF_SERVICE",
|
||||||
|
});
|
||||||
|
const privacyLink = screen.getByRole("link", {
|
||||||
|
name: "COMMON$PRIVACY_POLICY",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tosLink).toBeInTheDocument();
|
||||||
|
expect(tosLink).toHaveAttribute("href", "https://www.all-hands.dev/tos");
|
||||||
|
expect(tosLink).toHaveAttribute("target", "_blank");
|
||||||
|
expect(tosLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
|
||||||
|
expect(privacyLink).toBeInTheDocument();
|
||||||
|
expect(privacyLink).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://www.all-hands.dev/privacy",
|
||||||
|
);
|
||||||
|
expect(privacyLink).toHaveAttribute("target", "_blank");
|
||||||
|
expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render all required text content", () => {
|
||||||
|
// Arrange & Act
|
||||||
|
render(<TermsAndPrivacyNotice />);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const termsSection = screen.getByTestId("terms-and-privacy-notice");
|
||||||
|
expect(termsSection).toHaveTextContent(
|
||||||
|
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
|
||||||
|
);
|
||||||
|
expect(termsSection).toHaveTextContent("COMMON$TERMS_OF_SERVICE");
|
||||||
|
expect(termsSection).toHaveTextContent("COMMON$AND");
|
||||||
|
expect(termsSection).toHaveTextContent("COMMON$PRIVACY_POLICY");
|
||||||
|
});
|
||||||
|
});
|
||||||
242
frontend/__tests__/routes/root-layout.test.tsx
Normal file
242
frontend/__tests__/routes/root-layout.test.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createRoutesStub } from "react-router";
|
||||||
|
import MainApp from "#/routes/root-layout";
|
||||||
|
import OptionService from "#/api/option-service/option-service.api";
|
||||||
|
import AuthService from "#/api/auth-service/auth-service.api";
|
||||||
|
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||||
|
|
||||||
|
// Mock other hooks that are not the focus of these tests
|
||||||
|
vi.mock("#/hooks/use-github-auth-url", () => ({
|
||||||
|
useGitHubAuthUrl: () => "https://github.com/oauth/authorize",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("#/hooks/use-is-on-tos-page", () => ({
|
||||||
|
useIsOnTosPage: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("#/hooks/use-auto-login", () => ({
|
||||||
|
useAutoLogin: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("#/hooks/use-auth-callback", () => ({
|
||||||
|
useAuthCallback: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("#/hooks/use-migrate-user-consent", () => ({
|
||||||
|
useMigrateUserConsent: () => ({
|
||||||
|
migrateUserConsent: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("#/hooks/use-reo-tracking", () => ({
|
||||||
|
useReoTracking: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("#/hooks/use-sync-posthog-consent", () => ({
|
||||||
|
useSyncPostHogConsent: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||||
|
displaySuccessToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RouterStub = createRoutesStub([
|
||||||
|
{
|
||||||
|
Component: MainApp,
|
||||||
|
path: "/",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
Component: () => <div data-testid="outlet-content">Content</div>,
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("MainApp - Email Verification Flow", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Default mocks for services
|
||||||
|
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||||
|
APP_MODE: "saas",
|
||||||
|
GITHUB_CLIENT_ID: "test-client-id",
|
||||||
|
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||||
|
PROVIDERS_CONFIGURED: ["github"],
|
||||||
|
AUTH_URL: "https://auth.example.com",
|
||||||
|
FEATURE_FLAGS: {
|
||||||
|
ENABLE_BILLING: false,
|
||||||
|
HIDE_LLM_SETTINGS: false,
|
||||||
|
ENABLE_JIRA: false,
|
||||||
|
ENABLE_JIRA_DC: false,
|
||||||
|
ENABLE_LINEAR: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
|
||||||
|
|
||||||
|
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
|
||||||
|
language: "en",
|
||||||
|
user_consents_to_analytics: true,
|
||||||
|
llm_model: "",
|
||||||
|
llm_base_url: "",
|
||||||
|
agent: "",
|
||||||
|
llm_api_key: null,
|
||||||
|
llm_api_key_set: false,
|
||||||
|
search_api_key_set: false,
|
||||||
|
confirmation_mode: false,
|
||||||
|
security_analyzer: null,
|
||||||
|
remote_runtime_resource_factor: null,
|
||||||
|
provider_tokens_set: {},
|
||||||
|
enable_default_condenser: false,
|
||||||
|
condenser_max_size: null,
|
||||||
|
enable_sound_notifications: false,
|
||||||
|
enable_proactive_conversation_starters: false,
|
||||||
|
enable_solvability_analysis: false,
|
||||||
|
max_budget_per_task: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
vi.stubGlobal("localStorage", {
|
||||||
|
getItem: vi.fn(() => null),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display EmailVerificationModal when email_verification_required=true is in query params", async () => {
|
||||||
|
// Arrange & Act
|
||||||
|
render(
|
||||||
|
<RouterStub initialEntries={["/?email_verification_required=true"]} />,
|
||||||
|
{ wrapper: createWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set emailVerified state and pass to AuthModal when email_verified=true is in query params", async () => {
|
||||||
|
// Arrange
|
||||||
|
// Mock a 401 error to simulate unauthenticated user
|
||||||
|
const axiosError = {
|
||||||
|
response: { status: 401 },
|
||||||
|
isAxiosError: true,
|
||||||
|
};
|
||||||
|
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<RouterStub initialEntries={["/?email_verified=true"]} />, {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert - Wait for AuthModal to render (since user is not authenticated)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle both email_verification_required and email_verified params together", async () => {
|
||||||
|
// Arrange & Act
|
||||||
|
render(
|
||||||
|
<RouterStub
|
||||||
|
initialEntries={[
|
||||||
|
"/?email_verification_required=true&email_verified=true",
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert - EmailVerificationModal should take precedence
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove query parameters from URL after processing", async () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const { container } = render(
|
||||||
|
<RouterStub initialEntries={["/?email_verification_required=true"]} />,
|
||||||
|
{ wrapper: createWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert - Wait for the modal to appear (which indicates processing happened)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that the query parameter was processed by checking the modal appeared
|
||||||
|
// The hook removes the parameter from the URL, so we verify the behavior indirectly
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not display EmailVerificationModal when email_verification_required is not in query params", async () => {
|
||||||
|
// Arrange - No query params set
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<RouterStub />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByText("AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not display email verified message when email_verified is not in query params", async () => {
|
||||||
|
// Arrange
|
||||||
|
// Mock a 401 error to simulate unauthenticated user
|
||||||
|
const axiosError = {
|
||||||
|
response: { status: 401 },
|
||||||
|
isAxiosError: true,
|
||||||
|
};
|
||||||
|
vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<RouterStub />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Assert - AuthModal should render but without email verified message
|
||||||
|
await waitFor(() => {
|
||||||
|
const authModal = screen.queryByText(
|
||||||
|
"AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
|
||||||
|
);
|
||||||
|
if (authModal) {
|
||||||
|
expect(
|
||||||
|
screen.queryByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSearchParams } from "react-router";
|
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||||
@ -14,12 +13,15 @@ import { useAuthUrl } from "#/hooks/use-auth-url";
|
|||||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||||
import { Provider } from "#/types/settings";
|
import { Provider } from "#/types/settings";
|
||||||
import { useTracking } from "#/hooks/use-tracking";
|
import { useTracking } from "#/hooks/use-tracking";
|
||||||
|
import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice";
|
||||||
|
|
||||||
interface AuthModalProps {
|
interface AuthModalProps {
|
||||||
githubAuthUrl: string | null;
|
githubAuthUrl: string | null;
|
||||||
appMode?: GetConfigResponse["APP_MODE"] | null;
|
appMode?: GetConfigResponse["APP_MODE"] | null;
|
||||||
authUrl?: GetConfigResponse["AUTH_URL"];
|
authUrl?: GetConfigResponse["AUTH_URL"];
|
||||||
providersConfigured?: Provider[];
|
providersConfigured?: Provider[];
|
||||||
|
emailVerified?: boolean;
|
||||||
|
hasDuplicatedEmail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthModal({
|
export function AuthModal({
|
||||||
@ -27,11 +29,11 @@ export function AuthModal({
|
|||||||
appMode,
|
appMode,
|
||||||
authUrl,
|
authUrl,
|
||||||
providersConfigured,
|
providersConfigured,
|
||||||
|
emailVerified = false,
|
||||||
|
hasDuplicatedEmail = false,
|
||||||
}: AuthModalProps) {
|
}: AuthModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { trackLoginButtonClick } = useTracking();
|
const { trackLoginButtonClick } = useTracking();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const hasDuplicatedEmail = searchParams.get("duplicated_email") === "true";
|
|
||||||
|
|
||||||
const gitlabAuthUrl = useAuthUrl({
|
const gitlabAuthUrl = useAuthUrl({
|
||||||
appMode: appMode || null,
|
appMode: appMode || null,
|
||||||
@ -126,6 +128,13 @@ export function AuthModal({
|
|||||||
<ModalBackdrop>
|
<ModalBackdrop>
|
||||||
<ModalBody className="border border-tertiary">
|
<ModalBody className="border border-tertiary">
|
||||||
<OpenHandsLogo width={68} height={46} />
|
<OpenHandsLogo width={68} height={46} />
|
||||||
|
{emailVerified && (
|
||||||
|
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{hasDuplicatedEmail && (
|
{hasDuplicatedEmail && (
|
||||||
<div className="text-center text-danger text-sm mt-2 mb-2">
|
<div className="text-center text-danger text-sm mt-2 mb-2">
|
||||||
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
|
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
|
||||||
@ -206,30 +215,7 @@ export function AuthModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<TermsAndPrivacyNotice />
|
||||||
className="mt-4 text-xs text-center text-muted-foreground"
|
|
||||||
data-testid="auth-modal-terms-of-service"
|
|
||||||
>
|
|
||||||
{t(I18nKey.AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR)}{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.all-hands.dev/tos"
|
|
||||||
target="_blank"
|
|
||||||
className="underline hover:text-primary"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t(I18nKey.COMMON$TERMS_OF_SERVICE)}
|
|
||||||
</a>{" "}
|
|
||||||
{t(I18nKey.COMMON$AND)}{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.all-hands.dev/privacy"
|
|
||||||
target="_blank"
|
|
||||||
className="underline hover:text-primary"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t(I18nKey.COMMON$PRIVACY_POLICY)}
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</ModalBackdrop>
|
</ModalBackdrop>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
|
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||||
|
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||||
|
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||||
|
import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice";
|
||||||
|
|
||||||
|
interface EmailVerificationModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailVerificationModal({
|
||||||
|
onClose,
|
||||||
|
}: EmailVerificationModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalBackdrop onClose={onClose}>
|
||||||
|
<ModalBody className="border border-tertiary">
|
||||||
|
<OpenHandsLogo width={68} height={46} />
|
||||||
|
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{t(I18nKey.AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TermsAndPrivacyNotice />
|
||||||
|
</ModalBody>
|
||||||
|
</ModalBackdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/components/shared/terms-and-privacy-notice.tsx
Normal file
37
frontend/src/components/shared/terms-and-privacy-notice.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
|
|
||||||
|
interface TermsAndPrivacyNoticeProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TermsAndPrivacyNotice({
|
||||||
|
className = "mt-4 text-xs text-center text-muted-foreground",
|
||||||
|
}: TermsAndPrivacyNoticeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className={className} data-testid="terms-and-privacy-notice">
|
||||||
|
{t(I18nKey.AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR)}{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.all-hands.dev/tos"
|
||||||
|
target="_blank"
|
||||||
|
className="underline hover:text-primary"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t(I18nKey.COMMON$TERMS_OF_SERVICE)}
|
||||||
|
</a>{" "}
|
||||||
|
{t(I18nKey.COMMON$AND)}{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.all-hands.dev/privacy"
|
||||||
|
target="_blank"
|
||||||
|
className="underline hover:text-primary"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t(I18nKey.COMMON$PRIVACY_POLICY)}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
frontend/src/hooks/use-email-verification.ts
Normal file
63
frontend/src/hooks/use-email-verification.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to handle email verification logic from URL query parameters.
|
||||||
|
* Manages the email verification modal state and email verified state
|
||||||
|
* based on query parameters in the URL.
|
||||||
|
*
|
||||||
|
* @returns An object containing:
|
||||||
|
* - emailVerificationModalOpen: boolean state for modal visibility
|
||||||
|
* - setEmailVerificationModalOpen: function to control modal visibility
|
||||||
|
* - emailVerified: boolean state for email verification status
|
||||||
|
* - setEmailVerified: function to control email verification status
|
||||||
|
* - hasDuplicatedEmail: boolean state for duplicate email error status
|
||||||
|
*/
|
||||||
|
export function useEmailVerification() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [emailVerificationModalOpen, setEmailVerificationModalOpen] =
|
||||||
|
React.useState(false);
|
||||||
|
const [emailVerified, setEmailVerified] = React.useState(false);
|
||||||
|
const [hasDuplicatedEmail, setHasDuplicatedEmail] = React.useState(false);
|
||||||
|
|
||||||
|
// Check for email verification query parameters
|
||||||
|
React.useEffect(() => {
|
||||||
|
const emailVerificationRequired = searchParams.get(
|
||||||
|
"email_verification_required",
|
||||||
|
);
|
||||||
|
const emailVerifiedParam = searchParams.get("email_verified");
|
||||||
|
const duplicatedEmailParam = searchParams.get("duplicated_email");
|
||||||
|
let shouldUpdate = false;
|
||||||
|
|
||||||
|
if (emailVerificationRequired === "true") {
|
||||||
|
setEmailVerificationModalOpen(true);
|
||||||
|
searchParams.delete("email_verification_required");
|
||||||
|
shouldUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailVerifiedParam === "true") {
|
||||||
|
setEmailVerified(true);
|
||||||
|
searchParams.delete("email_verified");
|
||||||
|
shouldUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicatedEmailParam === "true") {
|
||||||
|
setHasDuplicatedEmail(true);
|
||||||
|
searchParams.delete("duplicated_email");
|
||||||
|
shouldUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the URL by removing parameters if any were found
|
||||||
|
if (shouldUpdate) {
|
||||||
|
setSearchParams(searchParams, { replace: true });
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emailVerificationModalOpen,
|
||||||
|
setEmailVerificationModalOpen,
|
||||||
|
emailVerified,
|
||||||
|
setEmailVerified,
|
||||||
|
hasDuplicatedEmail,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -730,6 +730,8 @@ export enum I18nKey {
|
|||||||
MICROAGENT_MANAGEMENT$USE_MICROAGENTS = "MICROAGENT_MANAGEMENT$USE_MICROAGENTS",
|
MICROAGENT_MANAGEMENT$USE_MICROAGENTS = "MICROAGENT_MANAGEMENT$USE_MICROAGENTS",
|
||||||
AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR = "AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
|
AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR = "AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
|
||||||
AUTH$NO_PROVIDERS_CONFIGURED = "AUTH$NO_PROVIDERS_CONFIGURED",
|
AUTH$NO_PROVIDERS_CONFIGURED = "AUTH$NO_PROVIDERS_CONFIGURED",
|
||||||
|
AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY = "AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY",
|
||||||
|
AUTH$EMAIL_VERIFIED_PLEASE_LOGIN = "AUTH$EMAIL_VERIFIED_PLEASE_LOGIN",
|
||||||
AUTH$DUPLICATE_EMAIL_ERROR = "AUTH$DUPLICATE_EMAIL_ERROR",
|
AUTH$DUPLICATE_EMAIL_ERROR = "AUTH$DUPLICATE_EMAIL_ERROR",
|
||||||
COMMON$TERMS_OF_SERVICE = "COMMON$TERMS_OF_SERVICE",
|
COMMON$TERMS_OF_SERVICE = "COMMON$TERMS_OF_SERVICE",
|
||||||
COMMON$AND = "COMMON$AND",
|
COMMON$AND = "COMMON$AND",
|
||||||
|
|||||||
@ -11679,6 +11679,38 @@
|
|||||||
"de": "Mindestens ein Identitätsanbieter muss konfiguriert werden (z.B. GitHub)",
|
"de": "Mindestens ein Identitätsanbieter muss konfiguriert werden (z.B. GitHub)",
|
||||||
"uk": "Принаймні один постачальник ідентифікації має бути налаштований (наприклад, GitHub)"
|
"uk": "Принаймні один постачальник ідентифікації має бути налаштований (наприклад, GitHub)"
|
||||||
},
|
},
|
||||||
|
"AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY": {
|
||||||
|
"en": "Please check your email to verify your account.",
|
||||||
|
"ja": "アカウントを確認するためにメールを確認してください。",
|
||||||
|
"zh-CN": "请检查您的电子邮件以验证您的账户。",
|
||||||
|
"zh-TW": "請檢查您的電子郵件以驗證您的帳戶。",
|
||||||
|
"ko-KR": "계정을 확인하려면 이메일을 확인하세요.",
|
||||||
|
"no": "Vennligst sjekk e-posten din for å bekrefte kontoen din.",
|
||||||
|
"it": "Controlla la tua email per verificare il tuo account.",
|
||||||
|
"pt": "Por favor, verifique seu e-mail para verificar sua conta.",
|
||||||
|
"es": "Por favor, verifica tu correo electrónico para verificar tu cuenta.",
|
||||||
|
"ar": "يرجى التحقق من بريدك الإلكتروني للتحقق من حسابك.",
|
||||||
|
"fr": "Veuillez vérifier votre e-mail pour vérifier votre compte.",
|
||||||
|
"tr": "Hesabınızı doğrulamak için lütfen e-postanızı kontrol edin.",
|
||||||
|
"de": "Bitte überprüfen Sie Ihre E-Mail, um Ihr Konto zu verifizieren.",
|
||||||
|
"uk": "Будь ласка, перевірте свою електронну пошту, щоб підтвердити свій обліковий запис."
|
||||||
|
},
|
||||||
|
"AUTH$EMAIL_VERIFIED_PLEASE_LOGIN": {
|
||||||
|
"en": "Your email has been verified. Please login below.",
|
||||||
|
"ja": "メールアドレスが確認されました。下記からログインしてください。",
|
||||||
|
"zh-CN": "您的电子邮件已验证。请在下方登录。",
|
||||||
|
"zh-TW": "您的電子郵件已驗證。請在下方登錄。",
|
||||||
|
"ko-KR": "이메일이 확인되었습니다. 아래에서 로그인하세요.",
|
||||||
|
"no": "E-posten din er bekreftet. Vennligst logg inn nedenfor.",
|
||||||
|
"it": "La tua email è stata verificata. Effettua il login qui sotto.",
|
||||||
|
"pt": "Seu e-mail foi verificado. Por favor, faça login abaixo.",
|
||||||
|
"es": "Tu correo electrónico ha sido verificado. Por favor, inicia sesión a continuación.",
|
||||||
|
"ar": "تم التحقق من بريدك الإلكتروني. يرجى تسجيل الدخول أدناه.",
|
||||||
|
"fr": "Votre e-mail a été vérifié. Veuillez vous connecter ci-dessous.",
|
||||||
|
"tr": "E-postanız doğrulandı. Lütfen aşağıdan giriş yapın.",
|
||||||
|
"de": "Ihre E-Mail wurde verifiziert. Bitte melden Sie sich unten an.",
|
||||||
|
"uk": "Вашу електронну пошту підтверджено. Будь ласка, увійдіть нижче."
|
||||||
|
},
|
||||||
"AUTH$DUPLICATE_EMAIL_ERROR": {
|
"AUTH$DUPLICATE_EMAIL_ERROR": {
|
||||||
"en": "Your account is unable to be created. Please use a different login or try again.",
|
"en": "Your account is unable to be created. Please use a different login or try again.",
|
||||||
"ja": "アカウントを作成できません。別のログインを使用するか、もう一度お試しください。",
|
"ja": "アカウントを作成できません。別のログインを使用するか、もう一度お試しください。",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { useConfig } from "#/hooks/query/use-config";
|
|||||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||||
import { AuthModal } from "#/components/features/waitlist/auth-modal";
|
import { AuthModal } from "#/components/features/waitlist/auth-modal";
|
||||||
import { ReauthModal } from "#/components/features/waitlist/reauth-modal";
|
import { ReauthModal } from "#/components/features/waitlist/reauth-modal";
|
||||||
|
import { EmailVerificationModal } from "#/components/features/waitlist/email-verification-modal";
|
||||||
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
|
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
|
||||||
import { useSettings } from "#/hooks/query/use-settings";
|
import { useSettings } from "#/hooks/query/use-settings";
|
||||||
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
|
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
|
||||||
@ -26,6 +27,7 @@ import { useAutoLogin } from "#/hooks/use-auto-login";
|
|||||||
import { useAuthCallback } from "#/hooks/use-auth-callback";
|
import { useAuthCallback } from "#/hooks/use-auth-callback";
|
||||||
import { useReoTracking } from "#/hooks/use-reo-tracking";
|
import { useReoTracking } from "#/hooks/use-reo-tracking";
|
||||||
import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent";
|
import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent";
|
||||||
|
import { useEmailVerification } from "#/hooks/use-email-verification";
|
||||||
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
|
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
|
||||||
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
|
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
|
||||||
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
|
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
|
||||||
@ -91,6 +93,12 @@ export default function MainApp() {
|
|||||||
const effectiveGitHubAuthUrl = isOnTosPage ? null : gitHubAuthUrl;
|
const effectiveGitHubAuthUrl = isOnTosPage ? null : gitHubAuthUrl;
|
||||||
|
|
||||||
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false);
|
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false);
|
||||||
|
const {
|
||||||
|
emailVerificationModalOpen,
|
||||||
|
setEmailVerificationModalOpen,
|
||||||
|
emailVerified,
|
||||||
|
hasDuplicatedEmail,
|
||||||
|
} = useEmailVerification();
|
||||||
|
|
||||||
// Auto-login if login method is stored in local storage
|
// Auto-login if login method is stored in local storage
|
||||||
useAutoLogin();
|
useAutoLogin();
|
||||||
@ -236,9 +244,18 @@ export default function MainApp() {
|
|||||||
appMode={config.data?.APP_MODE}
|
appMode={config.data?.APP_MODE}
|
||||||
providersConfigured={config.data?.PROVIDERS_CONFIGURED}
|
providersConfigured={config.data?.PROVIDERS_CONFIGURED}
|
||||||
authUrl={config.data?.AUTH_URL}
|
authUrl={config.data?.AUTH_URL}
|
||||||
|
emailVerified={emailVerified}
|
||||||
|
hasDuplicatedEmail={hasDuplicatedEmail}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{renderReAuthModal && <ReauthModal />}
|
{renderReAuthModal && <ReauthModal />}
|
||||||
|
{emailVerificationModalOpen && (
|
||||||
|
<EmailVerificationModal
|
||||||
|
onClose={() => {
|
||||||
|
setEmailVerificationModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (
|
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (
|
||||||
<AnalyticsConsentFormModal
|
<AnalyticsConsentFormModal
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user