diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 5b579cd7a..8fa38bf73 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -258,7 +258,7 @@ class UserRouterGroup(group.RouterGroup): except ControlPlaneDirectoryRequiredError as e: return self.http_status(409, e.code, str(e)) except account_errors.AccountEmailMismatchError as e: - return self.fail(3, str(e)) + return self.fail(getattr(e, 'code', 3), str(e)) except ValueError: self.ap.logger.exception('Space OAuth callback failed') return self.fail(1, 'Space OAuth failed') @@ -278,10 +278,22 @@ class UserRouterGroup(group.RouterGroup): ) @self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) - async def _(user_email: str) -> str: - """Get Space credits balance for current user""" - credits = await self.ap.space_service.get_credits(user_email) - return self.success(data={'credits': credits}) + async def _(request_context: RequestContext) -> str: + """Get Space credits using only the selected Workspace owner's credentials.""" + access = await self.ap.workspace_collaboration_service.resolve_account_workspace( + request_context.account_uuid, + request_context.workspace_uuid, + ) + owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid) + owner_space_bound = bool(owner and owner.space_account_uuid) + credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None + return self.success( + data={ + 'credits': credits, + 'owner_space_bound': owner_space_bound, + 'is_workspace_owner': access.membership.role == 'owner', + } + ) @self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> str: @@ -289,16 +301,10 @@ class UserRouterGroup(group.RouterGroup): if not await self.ap.user_service.is_initialized(): return self.success(data={'initialized': False}) - return self.success( - data={ - 'initialized': True, - # Login is selected per account in a multi-user instance. A public - # bootstrap endpoint must never project one user's authentication - # methods onto every other user or disclose that user's state. - 'password_login_enabled': getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud', - 'space_login_enabled': True, - } - ) + capabilities = await self.ap.user_service.get_login_capabilities() + if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': + capabilities['password_login_enabled'] = False + return self.success(data={'initialized': True, **capabilities}) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(user_email: str) -> str: @@ -369,8 +375,13 @@ class UserRouterGroup(group.RouterGroup): 'account_type': updated_user.account_type, } ) + except account_errors.AccountEmailMismatchError: + return self.http_status( + 409, + 'space_account_email_mismatch', + 'Bind the LangBot Account with the same email as this local Account', + ) except ValueError: - self.ap.logger.exception('Space account binding failed') return self.http_status(400, -1, 'Space account binding failed') except Exception: raise diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 6390777c6..5c43cf64b 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -311,6 +311,12 @@ class InvitationsRouterGroup(group.RouterGroup): authorization = quart.request.headers.get('Authorization', '') if authorization.startswith('Bearer '): + if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud': + return self.http_status( + 409, + 'invitation_logout_required', + 'Sign out before creating the invited local Account', + ) try: account = await self.ap.user_service.get_authenticated_account( authorization.removeprefix('Bearer ') @@ -345,7 +351,7 @@ class InvitationsRouterGroup(group.RouterGroup): if not isinstance(password, str) or len(password) < 8: return self.http_status(400, 'invalid_password', 'Password must contain at least 8 characters') try: - _, membership, token = await self.ap.user_service.register_invited_account( + _, membership = await self.ap.user_service.register_invited_account( invitation_token, str(registration.get('email', '')), password, @@ -354,4 +360,4 @@ class InvitationsRouterGroup(group.RouterGroup): return self.http_status(409, exc.code, str(exc)) except AccountExistsLoginRequiredError as exc: return self.http_status(409, exc.code, str(exc)) - return self.success(data={'token': token, 'workspace_uuid': membership.workspace_uuid}) + return self.success(data={'workspace_uuid': membership.workspace_uuid, 'login_required': True}) diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index f665ff8ac..001ac4309 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -15,10 +15,10 @@ import uuid from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ....entity.persistence import user +from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership from ....utils import constants from ....entity.errors import account as account_errors from ....workspace.collaboration import normalize_email -from ..authz import Permission, permissions_for_role if typing.TYPE_CHECKING: from ....core.app import Application @@ -151,8 +151,8 @@ class UserService: Space OAuth credentials belong to an Account, while model-provider secrets belong to a Workspace. Community edition has one unambiguous Workspace, so - the historical automatic refresh remains available to members allowed to - manage provider secrets. In multi-Workspace SaaS mode the OAuth callback has + the historical automatic refresh remains available only to the Workspace owner. + In multi-Workspace SaaS mode the OAuth callback has no trusted Workspace selector; the closed control plane or an explicit Workspace settings action must perform that linkage instead. """ @@ -170,7 +170,7 @@ class UserService: if len(accesses) != 1: return access = accesses[0] - if Permission.PROVIDER_SECRET_MANAGE.value not in permissions_for_role(access.membership.role): + if access.membership.role != MembershipRole.OWNER.value: return await self.ap.provider_service.update_space_model_provider_api_keys( access.workspace.uuid, @@ -184,6 +184,37 @@ class UserService: ) return account is not None + async def get_login_capabilities(self) -> dict[str, bool]: + """Derive enabled public login methods from all active Accounts.""" + password_count = sqlalchemy.func.count().filter( + user.User.password.is_not(None), user.User.password != '' + ) + space_count = sqlalchemy.func.count().filter(user.User.space_account_uuid.is_not(None)) + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(password_count, space_count).where( + user.User.status == user.AccountStatus.ACTIVE.value + ) + ) + password_accounts, space_accounts = result.one() + return { + 'password_login_enabled': bool(password_accounts), + 'space_login_enabled': bool(space_accounts), + } + + async def get_workspace_owner(self, workspace_uuid: str) -> user.User | None: + """Resolve the active owner Account for a Workspace.""" + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(user.User) + .join(WorkspaceMembership, WorkspaceMembership.account_uuid == user.User.uuid) + .where( + WorkspaceMembership.workspace_uuid == workspace_uuid, + WorkspaceMembership.role == MembershipRole.OWNER.value, + WorkspaceMembership.status == MembershipStatus.ACTIVE.value, + user.User.status == user.AccountStatus.ACTIVE.value, + ) + ) + return result.scalar_one_or_none() + def _session_factory(self) -> async_sessionmaker[AsyncSession]: return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False) @@ -230,7 +261,7 @@ class UserService: invitation_token: str, user_email: str, password: str, - ) -> tuple[user.User, typing.Any, str]: + ) -> tuple[user.User, typing.Any]: """Create an invited Account and accept its Membership in one transaction.""" normalized_email = normalize_email(user_email) @@ -261,8 +292,7 @@ class UserService: account.uuid, session=session, ) - token = await self.generate_jwt_token(account) - return account, membership, token + return account, membership def _new_account(self, normalized_email: str, hashed_password: str) -> user.User: return user.User( @@ -497,12 +527,12 @@ class UserService: # Account merely by presenting the same email. The Account # owner must first authenticate locally and use the explicit, # account-bound bind flow. - raise account_errors.AccountEmailMismatchError() + raise account_errors.SpaceAccountBindingRequiredError() # Check if system is already initialized is_initialized = await self.is_initialized() if is_initialized: - raise account_errors.AccountEmailMismatchError() + raise account_errors.SpaceAccountNotRegisteredError() # Create new Space user (first time initialization) if hasattr(self.ap.persistence_mgr, 'get_db_engine') and hasattr(self.ap, 'workspace_service'): @@ -707,6 +737,8 @@ class UserService: if not space_account_uuid or not space_email: raise ValueError('Invalid Space user info') + if normalize_email(space_email) != normalize_email(user_email): + raise account_errors.AccountEmailMismatchError() # Check if this Space account is already bound to another user existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid) diff --git a/src/langbot/pkg/entity/errors/account.py b/src/langbot/pkg/entity/errors/account.py index edd5b41fa..a2d0f1e85 100644 --- a/src/langbot/pkg/entity/errors/account.py +++ b/src/langbot/pkg/entity/errors/account.py @@ -2,5 +2,19 @@ from __future__ import annotations class AccountEmailMismatchError(Exception): - def __str__(self): + def __str__(self) -> str: return 'Account email mismatch' + + +class SpaceAccountNotRegisteredError(AccountEmailMismatchError): + code = 'space_account_not_registered' + + def __str__(self) -> str: + return 'No Account is registered for this Space email' + + +class SpaceAccountBindingRequiredError(AccountEmailMismatchError): + code = 'space_account_binding_required' + + def __str__(self) -> str: + return 'This local Account must bind Space from Account settings before Space login' diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 58a7c2a01..92087fca4 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -86,7 +86,7 @@ def fake_api_app(): 'api': {'port': 5300}, 'plugin': {'enable_marketplace': True}, 'space': {'url': 'https://space.langbot.app'}, - 'system': {'allow_modify_login_info': True, 'limitation': {}}, + 'system': {'allow_modify_login_info': True, 'recovery_key': 'recovery-secret', 'limitation': {}}, } ) @@ -291,6 +291,9 @@ class TestUserInitEndpoint: @pytest.mark.asyncio async def test_account_info_exposes_instance_capabilities_not_first_account(self, quart_test_client, fake_api_app): fake_api_app.user_service.is_initialized.return_value = True + fake_api_app.user_service.get_login_capabilities = AsyncMock( + return_value={'password_login_enabled': True, 'space_login_enabled': False} + ) fake_api_app.user_service.get_first_user = AsyncMock( side_effect=AssertionError('public login bootstrap must not inspect an account') ) @@ -302,10 +305,32 @@ class TestUserInitEndpoint: assert data['data'] == { 'initialized': True, 'password_login_enabled': True, - 'space_login_enabled': True, + 'space_login_enabled': False, } + fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with() fake_api_app.user_service.get_first_user.assert_not_awaited() + @pytest.mark.asyncio + async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch): + fake_api_app.user_service.is_initialized.return_value = True + fake_api_app.user_service.get_user_by_email.return_value = Mock(user='member@example.com') + fake_api_app.user_service.reset_password = AsyncMock() + monkeypatch.setattr('langbot.pkg.api.http.controller.groups.user.asyncio.sleep', AsyncMock()) + + response = await quart_test_client.post( + '/api/v1/user/reset-password', + json={ + 'user': 'member@example.com', + 'recovery_key': 'recovery-secret', + 'new_password': 'new-member-password', + }, + ) + + assert response.status_code == 200 + fake_api_app.user_service.reset_password.assert_awaited_once_with( + 'member@example.com', 'new-member-password' + ) + @pytest.mark.usefixtures('mock_circular_import_chain') class TestRealImports: diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index a662a715e..17569707c 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -260,6 +260,28 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut ) +@pytest.mark.asyncio +async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): + application, client = space_oauth_api + application.user_service.get_workspace_owner = AsyncMock( + return_value=SimpleNamespace(user='owner@example.com', space_account_uuid='space-owner') + ) + application.space_service.get_credits = AsyncMock(return_value=25000) + + response = await client.get( + '/api/v1/user/space-credits', + headers={'Authorization': 'Bearer account-token', 'X-Workspace-UUID': WORKSPACE_UUID}, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data'] == { + 'credits': 25000, + 'owner_space_bound': True, + 'is_workspace_owner': True, + } + application.space_service.get_credits.assert_awaited_once_with('owner@example.com') + + @pytest.mark.asyncio async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api): application, client = space_oauth_api diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 3414f5561..7547f4800 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -231,8 +231,15 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac }, ) assert accept_response.status_code == 200 - member_auth = (await accept_response.get_json())['data'] - assert member_auth['workspace_uuid'] == workspace_uuid + member_registration = (await accept_response.get_json())['data'] + assert member_registration == {'workspace_uuid': workspace_uuid, 'login_required': True} + + member_login_response = await client.post( + '/api/v1/user/auth', + json={'user': 'member@example.com', 'password': 'member-password'}, + ) + assert member_login_response.status_code == 200 + member_token = (await member_login_response.get_json())['data']['token'] reused_response = await client.post( '/api/v1/invitations/accept', @@ -249,7 +256,7 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac member_current_response = await client.get( '/api/v1/workspaces/current', - headers=_auth(member_auth['token'], workspace_uuid), + headers=_auth(member_token, workspace_uuid), ) assert member_current_response.status_code == 200 member_current = (await member_current_response.get_json())['data'] @@ -258,15 +265,29 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac forbidden_invite = await client.post( f'/api/v1/workspaces/{workspace_uuid}/invitations', - headers=_auth(member_auth['token'], workspace_uuid), + headers=_auth(member_token, workspace_uuid), json={'email': 'third@example.com', 'role': 'viewer'}, ) assert forbidden_invite.status_code == 403 assert (await forbidden_invite.get_json())['code'] == 'permission_denied' -async def test_invitation_accept_rejects_invalid_bearer_as_authentication_failure(workspace_api): - _, client, _, _ = workspace_api +async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api): + _, client, _, owner_token = workspace_api + + response = await client.post( + '/api/v1/invitations/accept', + headers={'Authorization': f'Bearer {owner_token}'}, + json={'token': 'lbi_pending-invitation'}, + ) + + assert response.status_code == 409 + assert (await response.get_json())['code'] == 'invitation_logout_required' + + +async def test_invalid_bearer_on_cloud_invitation_is_authentication_failure(workspace_api): + application, client, _, _ = workspace_api + application.deployment = SimpleNamespace(mode='cloud') response = await client.post( '/api/v1/invitations/accept', @@ -368,7 +389,14 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac 'registration': {'email': 'viewer@example.com', 'password': 'viewer-password'}, }, ) - viewer_token = (await accept_response.get_json())['data']['token'] + assert accept_response.status_code == 200 + assert (await accept_response.get_json())['data']['login_required'] is True + login_response = await client.post( + '/api/v1/user/auth', + json={'user': 'viewer@example.com', 'password': 'viewer-password'}, + ) + assert login_response.status_code == 200 + viewer_token = (await login_response.get_json())['data']['token'] forbidden = await client.post( '/api/v1/apikeys', headers=_auth(viewer_token, workspace_uuid), @@ -382,6 +410,7 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in workspace_api, ): application, client, engine, owner_token = workspace_api + application.deployment = SimpleNamespace(mode='cloud') owner_uuid = jwt.decode( owner_token, 'workspace-api-secret', @@ -537,8 +566,8 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in 'registration': {'email': 'member@example.com', 'password': 'member-password'}, }, ) - assert registration_response.status_code == 409 - assert (await registration_response.get_json())['code'] == 'control_plane_required' + assert registration_response.status_code == 401 + assert (await registration_response.get_json())['code'] == 'account_exists_login_required' async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api): diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index ab22712c1..84f936a6b 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -24,7 +24,11 @@ from langbot.pkg.api.http.service.user import ( UserService, ) from langbot.pkg.entity.persistence.user import AccountSource, AccountStatus, User -from langbot.pkg.entity.errors.account import AccountEmailMismatchError +from langbot.pkg.entity.errors.account import ( + AccountEmailMismatchError, + SpaceAccountBindingRequiredError, + SpaceAccountNotRegisteredError, +) pytestmark = pytest.mark.asyncio @@ -97,6 +101,7 @@ def _create_mock_user( """Helper to create mock User entity.""" user = Mock(spec=User) user.user = email + user.uuid = f'account-{email}' user.password = password user.account_type = account_type user.space_account_uuid = space_account_uuid @@ -694,8 +699,8 @@ class TestUserServiceCreateOrUpdateSpaceUser: # Verify assert result.space_account_uuid == 'new-space-uuid' - async def test_create_or_update_space_user_already_initialized_raises_error(self): - """Raises AccountEmailMismatchError when system already initialized and user not found.""" + async def test_create_or_update_space_user_already_initialized_reports_unknown_space_email(self): + """Unknown Space email is distinct from an existing local Account collision.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() @@ -710,7 +715,7 @@ class TestUserServiceCreateOrUpdateSpaceUser: service.is_initialized = AsyncMock(return_value=True) # Already initialized # Execute & Verify - with pytest.raises(AccountEmailMismatchError): + with pytest.raises(SpaceAccountNotRegisteredError): await service.create_or_update_space_user( space_account_uuid='unknown-space-uuid', email='unknown@example.com', @@ -747,7 +752,7 @@ class TestUserServiceCreateOrUpdateSpaceUser: service.get_user_by_email = AsyncMock(return_value=existing_user) service.generate_jwt_token = AsyncMock(return_value='must-not-be-issued') - with pytest.raises(AccountEmailMismatchError): + with pytest.raises(SpaceAccountBindingRequiredError): await service.authenticate_space_user( 'attacker-access-token', 'attacker-refresh-token', @@ -758,6 +763,46 @@ class TestUserServiceCreateOrUpdateSpaceUser: ap.provider_service.update_space_model_provider_api_keys.assert_not_awaited() service.generate_jwt_token.assert_not_awaited() + async def test_oss_space_provider_refresh_requires_workspace_owner(self): + member_account = _create_mock_user(email='member@example.com', space_account_uuid='space-member') + access = SimpleNamespace( + workspace=SimpleNamespace(uuid='workspace-a'), + membership=SimpleNamespace(role='admin'), + ) + provider_service = SimpleNamespace(update_space_model_provider_api_keys=AsyncMock()) + ap = SimpleNamespace( + workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=False)), + workspace_collaboration_service=SimpleNamespace( + list_account_workspaces=AsyncMock(return_value=[access]) + ), + provider_service=provider_service, + ) + + await UserService(ap)._update_space_provider_for_account(member_account, 'member-api-key') + + provider_service.update_space_model_provider_api_keys.assert_not_awaited() + + async def test_oss_space_provider_refresh_uses_workspace_owner_credentials(self): + owner_account = _create_mock_user(email='owner@example.com', space_account_uuid='space-owner') + access = SimpleNamespace( + workspace=SimpleNamespace(uuid='workspace-a'), + membership=SimpleNamespace(role='owner'), + ) + provider_service = SimpleNamespace(update_space_model_provider_api_keys=AsyncMock()) + ap = SimpleNamespace( + workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=False)), + workspace_collaboration_service=SimpleNamespace( + list_account_workspaces=AsyncMock(return_value=[access]) + ), + provider_service=provider_service, + ) + + await UserService(ap)._update_space_provider_for_account(owner_account, 'owner-api-key') + + provider_service.update_space_model_provider_api_keys.assert_awaited_once_with( + 'workspace-a', 'owner-api-key' + ) + async def test_create_or_update_space_user_no_expiry(self): """Creates Space user without token expiry.""" # Setup @@ -805,6 +850,49 @@ class TestUserServiceCreateOrUpdateSpaceUser: assert result.space_account_uuid == 'noexpiry-uuid' + async def test_bind_space_account_rejects_different_email(self): + service = UserService(SimpleNamespace()) + service.get_user_by_email = AsyncMock( + return_value=_create_mock_user(email='invited@example.com') + ) + service.ap.space_service = SimpleNamespace( + exchange_oauth_code=AsyncMock( + return_value={'access_token': 'access', 'refresh_token': 'refresh', 'expires_in': 3600} + ), + get_user_info_raw=AsyncMock( + return_value={ + 'account': {'uuid': 'space-other', 'email': 'other@example.com'}, + 'api_key': 'key', + } + ), + ) + service.get_user_by_space_account_uuid = AsyncMock(return_value=None) + service._identity_execute = AsyncMock() + + with pytest.raises(AccountEmailMismatchError): + await service.bind_space_account('invited@example.com', 'code') + + service._identity_execute.assert_not_awaited() + + +class TestUserServiceLoginCapabilities: + async def test_capabilities_are_derived_from_all_accounts(self): + result = SimpleNamespace(one=lambda: (2, 1)) + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=result))) + + capabilities = await UserService(ap).get_login_capabilities() + + assert capabilities == {'password_login_enabled': True, 'space_login_enabled': True} + + async def test_capabilities_disable_absent_login_methods(self): + result = SimpleNamespace(one=lambda: (0, 0)) + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=result))) + + capabilities = await UserService(ap).get_login_capabilities() + + assert capabilities == {'password_login_enabled': False, 'space_login_enabled': False} + + class TestUserServiceCreateUserLock: """Tests for create_user_lock attribute.""" diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index 2884fd951..33f3a4846 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -68,6 +68,9 @@ function SpaceOAuthCallbackContent() { 'loading' | 'confirm' | 'success' | 'error' >('loading'); const [errorMessage, setErrorMessage] = useState(''); + const [terminalErrorCode, setTerminalErrorCode] = useState< + 'space_account_not_registered' | 'space_account_binding_required' | null + >(null); const [isBindMode, setIsBindMode] = useState(false); const [code, setCode] = useState(null); const [isProcessing, setIsProcessing] = useState(false); @@ -125,7 +128,15 @@ function SpaceOAuthCallbackContent() { } setStatus('error'); - const errorObj = err as { msg?: string }; + const errorObj = err as { code?: string; msg?: string }; + if ( + errorObj.code === 'space_account_not_registered' || + errorObj.code === 'space_account_binding_required' + ) { + setTerminalErrorCode(errorObj.code); + setErrorMessage(t(`account.${errorObj.code}`)); + return; + } const errMsg = (errorObj?.msg || '').toLowerCase(); if (errMsg.includes('account email mismatch')) { setErrorMessage(t('account.spaceEmailMismatch')); @@ -168,7 +179,11 @@ function SpaceOAuthCallbackContent() { } setStatus('error'); - const errorObj = err as { msg?: string }; + const errorObj = err as { code?: string; msg?: string }; + if (errorObj.code === 'space_account_email_mismatch') { + setErrorMessage(t('account.spaceEmailMismatch')); + return; + } const errMsg = (errorObj?.msg || '').toLowerCase(); if (errMsg.includes('account email mismatch')) { setErrorMessage(t('account.spaceEmailMismatch')); @@ -278,9 +293,11 @@ function SpaceOAuthCallbackContent() { ? t('account.bindSpaceSuccess') : t('common.spaceLoginSuccess'))} {status === 'error' && - (isBindMode - ? t('account.bindSpaceFailed') - : t('common.spaceLoginError'))} + (terminalErrorCode + ? t(`account.${terminalErrorCode}Title`) + : isBindMode + ? t('account.bindSpaceFailed') + : t('common.spaceLoginError'))} {status === 'loading' && diff --git a/web/src/app/home/components/models-dialog/ModelsPanel.tsx b/web/src/app/home/components/models-dialog/ModelsPanel.tsx index 22c3525a6..d04e041b4 100644 --- a/web/src/app/home/components/models-dialog/ModelsPanel.tsx +++ b/web/src/app/home/components/models-dialog/ModelsPanel.tsx @@ -25,6 +25,7 @@ import { import { CustomApiError } from '@/app/infra/entities/common'; import { PanelBody } from '../settings-dialog/panel-layout'; import { useCurrentWorkspace } from '@/app/infra/http'; +import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace'; interface ModelsPanelProps { // True when this panel is the active section and the dialog is open. @@ -89,8 +90,8 @@ export default function ModelsPanel({ currentWorkspace?.permissions.includes('provider_secret.manage') ?? false; const [providers, setProviders] = useState([]); - const [accountType, setAccountType] = useState<'local' | 'space'>('local'); - const [spaceCredits, setSpaceCredits] = useState(null); + const [spaceBilling, setSpaceBilling] = + useState(null); // Expanded providers and their models const [expandedProviders, setExpandedProviders] = useState>( @@ -144,7 +145,7 @@ export default function ModelsPanel({ useEffect(() => { if (active) { - loadUserInfo(); + loadWorkspaceBilling(); loadProviders(); loadRequesterSupportTypes(); } @@ -167,16 +168,11 @@ export default function ModelsPanel({ } }, [providersLoaded, providers]); - async function loadUserInfo() { + async function loadWorkspaceBilling() { try { - const userInfo = await httpClient.getUserInfo(); - setAccountType(userInfo.account_type); - if (userInfo.account_type === 'space') { - const creditsInfo = await httpClient.getSpaceCredits(); - setSpaceCredits(creditsInfo.credits); - } + setSpaceBilling(await httpClient.getWorkspaceSpaceBilling()); } catch { - setAccountType('local'); + setSpaceBilling(null); } } @@ -546,8 +542,9 @@ export default function ModelsPanel({ isExpanded={expandedProviders.has(provider.uuid)} isLoading={loadingProviders.has(provider.uuid)} models={providerModels[provider.uuid]} - accountType={accountType} - spaceCredits={spaceCredits} + isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'} + ownerSpaceBound={spaceBilling?.owner_space_bound ?? false} + spaceCredits={spaceBilling?.credits ?? null} addModelPopoverOpen={addModelPopoverOpen} editModelPopoverOpen={editModelPopoverOpen} deleteConfirmOpen={deleteConfirmOpen} diff --git a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx index 79d62b1ab..b771d77bc 100644 --- a/web/src/app/home/components/models-dialog/components/ProviderCard.tsx +++ b/web/src/app/home/components/models-dialog/components/ProviderCard.tsx @@ -44,7 +44,8 @@ interface ProviderCardProps { isExpanded: boolean; isLoading: boolean; models?: ProviderModels; - accountType: 'local' | 'space'; + isWorkspaceOwner: boolean; + ownerSpaceBound: boolean; spaceCredits: number | null; // Popover states addModelPopoverOpen: string | null; @@ -108,7 +109,8 @@ export default function ProviderCard({ isExpanded, isLoading, models, - accountType, + isWorkspaceOwner, + ownerSpaceBound, spaceCredits, addModelPopoverOpen, editModelPopoverOpen, @@ -198,7 +200,7 @@ export default function ProviderCard({
- {canManage && isLangBotModels && accountType !== 'space' && ( + {isLangBotModels && isWorkspaceOwner && !ownerSpaceBound && ( )} - {isLangBotModels && - accountType === 'space' && - spaceCredits !== null && ( -
- - {(spaceCredits / 5000).toFixed(2)} {t('models.credits')} - - -
- )} + {isLangBotModels && ownerSpaceBound && spaceCredits !== null && ( +
+ + {(spaceCredits / 5000).toFixed(2)} {t('models.credits')} + + +
+ )} + {isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && ( + + {t('models.usesOwnerSpaceBilling')} + + )} + {isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && ( + + {t('models.ownerMustBindSpace')} + + )} {canManage && !isLangBotModels && ( <> - ) : hasLoginToken ? ( + {hasLoginToken ? (
- {t('workspace.invitationEmailMismatch')} + {t('workspace.authenticatedInvitationNotice')}
-
) : passwordRegistrationEnabled ? ( diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 85fa8206e..668e17405 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -279,6 +279,10 @@ const enUS = { credits: 'Credits', loginWithSpace: 'Login with LangBot Account', loginToUseModels: 'Login with Space to use cloud models', + ownerMustBindSpace: + 'The Workspace owner must connect Space for LangBot Models.', + usesOwnerSpaceBilling: + "Uses the Workspace owner's Space billing and credits.", noModels: 'No models configured', langbotModels: 'LangBot Models', spaceTrialTooltip: @@ -1283,7 +1287,13 @@ const enUS = { 'Invalid bind request. Please try again from account settings.', setPasswordHint: 'Set a password to login with email and password', spaceEmailMismatch: - 'Space login email does not match the local account email', + 'The Space login email does not match the local account email.', + space_account_not_registeredTitle: 'Account not registered', + space_account_not_registered: + 'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', + space_account_binding_requiredTitle: 'Space connection required', + space_account_binding_required: + 'This local account must connect Space from Account settings before using Space login.', }, workspace: { title: 'Workspace', @@ -1339,6 +1349,9 @@ const enUS = { existingAccountLoginRequired: 'An account already exists for this email. Sign in to continue.', acceptAsCurrentAccount: 'Accept with current account', + authenticatedInvitationNotice: + 'Sign out first, then sign in with the invited account. Your invitation will be preserved.', + logoutAndReturn: 'Sign out and return to this invitation', switchAccount: 'Switch account', registerAndAccept: 'Create account and accept', alreadyHaveAccount: 'I already have an account', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index def29b657..8591f8506 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -284,6 +284,10 @@ const jaJP = { credits: 'クレジット', loginWithSpace: 'LangBot アカウントでログイン', loginToUseModels: 'Space でログインしてクラウドモデルを使用', + ownerMustBindSpace: + 'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。', + usesOwnerSpaceBilling: + 'ワークスペース所有者の Space 課金とクレジットを使用します。', noModels: 'モデルがありません', langbotModels: 'LangBot モデル', spaceTrialTooltip: @@ -1289,6 +1293,12 @@ const jaJP = { 'パスワードを設定するとメールとパスワードでログインできます', spaceEmailMismatch: 'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません', + space_account_not_registeredTitle: 'アカウントが登録されていません', + space_account_not_registered: + 'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。', + space_account_binding_requiredTitle: 'Space の連携が必要です', + space_account_binding_required: + 'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。', }, workspace: { title: 'ワークスペース', @@ -1340,6 +1350,9 @@ const jaJP = { existingAccountLoginRequired: 'このメールアドレスのアカウントは既に存在します。ログインしてください。', acceptAsCurrentAccount: '現在のアカウントで承認', + authenticatedInvitationNotice: + '一度ログアウトし、招待されたアカウントでログインしてください。招待は保持されます。', + logoutAndReturn: 'ログアウトしてこの招待に戻る', switchAccount: 'アカウントを切り替える', registerAndAccept: 'アカウントを作成して承認', alreadyHaveAccount: 'アカウントを持っています', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 8699cef30..fa4d56911 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -267,6 +267,8 @@ const zhHans = { credits: '积分', loginWithSpace: '使用 LangBot 账号登录', loginToUseModels: '通过 Space 登录以使用云端模型', + ownerMustBindSpace: '工作区所有者需要绑定 Space 才能使用 LangBot 模型。', + usesOwnerSpaceBilling: '使用工作区所有者的 Space 计费与积分。', noModels: '暂无模型', langbotModels: 'LangBot 模型', spaceTrialTooltip: @@ -1219,6 +1221,12 @@ const zhHans = { bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起', setPasswordHint: '设置密码后可使用邮箱密码登录', spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配', + space_account_not_registeredTitle: '账户尚未注册', + space_account_not_registered: + '此 Space 邮箱尚无本地账户,请联系工作区所有者获取邀请。', + space_account_binding_requiredTitle: '需要绑定 Space', + space_account_binding_required: + '此本地账户必须先在账户设置中绑定 Space,才能使用 Space 登录。', }, workspace: { title: '工作区', @@ -1270,6 +1278,9 @@ const zhHans = { invitationEmailMismatch: '此邀请属于另一个邮箱地址。', existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。', acceptAsCurrentAccount: '使用当前账户接受', + authenticatedInvitationNotice: + '请先退出,再使用受邀账户登录。邀请令牌会被保留。', + logoutAndReturn: '退出并返回此邀请', switchAccount: '切换账号', registerAndAccept: '创建账户并接受', alreadyHaveAccount: '我已有账户', diff --git a/web/tests/unit/oss-account-space-billing.test.mjs b/web/tests/unit/oss-account-space-billing.test.mjs new file mode 100644 index 000000000..963b78ba8 --- /dev/null +++ b/web/tests/unit/oss-account-space-billing.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('invited local registration returns to login instead of authenticating', () => { + const source = read('src/app/invitations/accept/page.tsx'); + assert.doesNotMatch( + source, + /beginAuthenticatedSession\([\s\S]{0,120}response\.token/, + ); + assert.match(source, /navigate\('\/login\?invitation=1'/); +}); + +test('authenticated invitation page offers logout while retaining invitation', () => { + const source = read('src/app/invitations/accept/page.tsx'); + assert.match(source, /workspace\.logoutAndReturn/); + assert.match(source, /setPendingInvitationToken\(token\)/); +}); + +test('Space OAuth callback distinguishes unknown and unbound accounts by stable codes', () => { + const source = read('src/app/auth/space/callback/page.tsx'); + assert.match(source, /space_account_not_registered/); + assert.match(source, /space_account_binding_required/); +}); + +test('models panel derives LangBot Models billing state from workspace owner', () => { + const source = read('src/app/home/components/models-dialog/ModelsPanel.tsx'); + assert.match(source, /getWorkspaceSpaceBilling/); + assert.doesNotMatch(source, /getSpaceCredits\(\)/); + assert.match(source, /membership\.role === 'owner'/); +}); + +test('provider card represents owner and member owner-bound states explicitly', () => { + const source = read( + 'src/app/home/components/models-dialog/components/ProviderCard.tsx', + ); + assert.match(source, /isWorkspaceOwner/); + assert.match(source, /ownerSpaceBound/); + assert.match(source, /models\.ownerMustBindSpace/); + assert.match(source, /models\.usesOwnerSpaceBilling/); +});