feat(oss): enforce invitation account and owner billing flows

This commit is contained in:
dadachann
2026-07-26 16:04:33 +08:00
parent 602e10649b
commit 712f79ed77
18 changed files with 449 additions and 121 deletions
@@ -258,7 +258,7 @@ class UserRouterGroup(group.RouterGroup):
except ControlPlaneDirectoryRequiredError as e: except ControlPlaneDirectoryRequiredError as e:
return self.http_status(409, e.code, str(e)) return self.http_status(409, e.code, str(e))
except account_errors.AccountEmailMismatchError as e: except account_errors.AccountEmailMismatchError as e:
return self.fail(3, str(e)) return self.fail(getattr(e, 'code', 3), str(e))
except ValueError: except ValueError:
self.ap.logger.exception('Space OAuth callback failed') self.ap.logger.exception('Space OAuth callback failed')
return self.fail(1, 'Space OAuth 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) @self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str: async def _(request_context: RequestContext) -> str:
"""Get Space credits balance for current user""" """Get Space credits using only the selected Workspace owner's credentials."""
credits = await self.ap.space_service.get_credits(user_email) access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
return self.success(data={'credits': credits}) 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) @self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str: async def _() -> str:
@@ -289,16 +301,10 @@ class UserRouterGroup(group.RouterGroup):
if not await self.ap.user_service.is_initialized(): if not await self.ap.user_service.is_initialized():
return self.success(data={'initialized': False}) return self.success(data={'initialized': False})
return self.success( capabilities = await self.ap.user_service.get_login_capabilities()
data={ if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
'initialized': True, capabilities['password_login_enabled'] = False
# Login is selected per account in a multi-user instance. A public return self.success(data={'initialized': True, **capabilities})
# 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,
}
)
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str: async def _(user_email: str) -> str:
@@ -369,8 +375,13 @@ class UserRouterGroup(group.RouterGroup):
'account_type': updated_user.account_type, '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: except ValueError:
self.ap.logger.exception('Space account binding failed')
return self.http_status(400, -1, 'Space account binding failed') return self.http_status(400, -1, 'Space account binding failed')
except Exception: except Exception:
raise raise
@@ -311,6 +311,12 @@ class InvitationsRouterGroup(group.RouterGroup):
authorization = quart.request.headers.get('Authorization', '') authorization = quart.request.headers.get('Authorization', '')
if authorization.startswith('Bearer '): 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: try:
account = await self.ap.user_service.get_authenticated_account( account = await self.ap.user_service.get_authenticated_account(
authorization.removeprefix('Bearer ') authorization.removeprefix('Bearer ')
@@ -345,7 +351,7 @@ class InvitationsRouterGroup(group.RouterGroup):
if not isinstance(password, str) or len(password) < 8: if not isinstance(password, str) or len(password) < 8:
return self.http_status(400, 'invalid_password', 'Password must contain at least 8 characters') return self.http_status(400, 'invalid_password', 'Password must contain at least 8 characters')
try: try:
_, membership, token = await self.ap.user_service.register_invited_account( _, membership = await self.ap.user_service.register_invited_account(
invitation_token, invitation_token,
str(registration.get('email', '')), str(registration.get('email', '')),
password, password,
@@ -354,4 +360,4 @@ class InvitationsRouterGroup(group.RouterGroup):
return self.http_status(409, exc.code, str(exc)) return self.http_status(409, exc.code, str(exc))
except AccountExistsLoginRequiredError as exc: except AccountExistsLoginRequiredError as exc:
return self.http_status(409, exc.code, str(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})
+41 -9
View File
@@ -15,10 +15,10 @@ import uuid
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import user from ....entity.persistence import user
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
from ....utils import constants from ....utils import constants
from ....entity.errors import account as account_errors from ....entity.errors import account as account_errors
from ....workspace.collaboration import normalize_email from ....workspace.collaboration import normalize_email
from ..authz import Permission, permissions_for_role
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from ....core.app import Application from ....core.app import Application
@@ -151,8 +151,8 @@ class UserService:
Space OAuth credentials belong to an Account, while model-provider secrets Space OAuth credentials belong to an Account, while model-provider secrets
belong to a Workspace. Community edition has one unambiguous Workspace, so belong to a Workspace. Community edition has one unambiguous Workspace, so
the historical automatic refresh remains available to members allowed to the historical automatic refresh remains available only to the Workspace owner.
manage provider secrets. In multi-Workspace SaaS mode the OAuth callback has In multi-Workspace SaaS mode the OAuth callback has
no trusted Workspace selector; the closed control plane or an explicit no trusted Workspace selector; the closed control plane or an explicit
Workspace settings action must perform that linkage instead. Workspace settings action must perform that linkage instead.
""" """
@@ -170,7 +170,7 @@ class UserService:
if len(accesses) != 1: if len(accesses) != 1:
return return
access = accesses[0] 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 return
await self.ap.provider_service.update_space_model_provider_api_keys( await self.ap.provider_service.update_space_model_provider_api_keys(
access.workspace.uuid, access.workspace.uuid,
@@ -184,6 +184,37 @@ class UserService:
) )
return account is not None 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]: def _session_factory(self) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False) return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
@@ -230,7 +261,7 @@ class UserService:
invitation_token: str, invitation_token: str,
user_email: str, user_email: str,
password: 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.""" """Create an invited Account and accept its Membership in one transaction."""
normalized_email = normalize_email(user_email) normalized_email = normalize_email(user_email)
@@ -261,8 +292,7 @@ class UserService:
account.uuid, account.uuid,
session=session, session=session,
) )
token = await self.generate_jwt_token(account) return account, membership
return account, membership, token
def _new_account(self, normalized_email: str, hashed_password: str) -> user.User: def _new_account(self, normalized_email: str, hashed_password: str) -> user.User:
return user.User( return user.User(
@@ -497,12 +527,12 @@ class UserService:
# Account merely by presenting the same email. The Account # Account merely by presenting the same email. The Account
# owner must first authenticate locally and use the explicit, # owner must first authenticate locally and use the explicit,
# account-bound bind flow. # account-bound bind flow.
raise account_errors.AccountEmailMismatchError() raise account_errors.SpaceAccountBindingRequiredError()
# Check if system is already initialized # Check if system is already initialized
is_initialized = await self.is_initialized() is_initialized = await self.is_initialized()
if is_initialized: if is_initialized:
raise account_errors.AccountEmailMismatchError() raise account_errors.SpaceAccountNotRegisteredError()
# Create new Space user (first time initialization) # Create new Space user (first time initialization)
if hasattr(self.ap.persistence_mgr, 'get_db_engine') and hasattr(self.ap, 'workspace_service'): 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: if not space_account_uuid or not space_email:
raise ValueError('Invalid Space user info') 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 # 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) existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
+15 -1
View File
@@ -2,5 +2,19 @@ from __future__ import annotations
class AccountEmailMismatchError(Exception): class AccountEmailMismatchError(Exception):
def __str__(self): def __str__(self) -> str:
return 'Account email mismatch' 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'
+27 -2
View File
@@ -86,7 +86,7 @@ def fake_api_app():
'api': {'port': 5300}, 'api': {'port': 5300},
'plugin': {'enable_marketplace': True}, 'plugin': {'enable_marketplace': True},
'space': {'url': 'https://space.langbot.app'}, '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 @pytest.mark.asyncio
async def test_account_info_exposes_instance_capabilities_not_first_account(self, quart_test_client, fake_api_app): 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.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( fake_api_app.user_service.get_first_user = AsyncMock(
side_effect=AssertionError('public login bootstrap must not inspect an account') side_effect=AssertionError('public login bootstrap must not inspect an account')
) )
@@ -302,10 +305,32 @@ class TestUserInitEndpoint:
assert data['data'] == { assert data['data'] == {
'initialized': True, 'initialized': True,
'password_login_enabled': 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() 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') @pytest.mark.usefixtures('mock_circular_import_chain')
class TestRealImports: class TestRealImports:
@@ -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 @pytest.mark.asyncio
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api): async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
+38 -9
View File
@@ -231,8 +231,15 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
}, },
) )
assert accept_response.status_code == 200 assert accept_response.status_code == 200
member_auth = (await accept_response.get_json())['data'] member_registration = (await accept_response.get_json())['data']
assert member_auth['workspace_uuid'] == workspace_uuid 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( reused_response = await client.post(
'/api/v1/invitations/accept', '/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( member_current_response = await client.get(
'/api/v1/workspaces/current', '/api/v1/workspaces/current',
headers=_auth(member_auth['token'], workspace_uuid), headers=_auth(member_token, workspace_uuid),
) )
assert member_current_response.status_code == 200 assert member_current_response.status_code == 200
member_current = (await member_current_response.get_json())['data'] 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( forbidden_invite = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations', 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'}, json={'email': 'third@example.com', 'role': 'viewer'},
) )
assert forbidden_invite.status_code == 403 assert forbidden_invite.status_code == 403
assert (await forbidden_invite.get_json())['code'] == 'permission_denied' assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
async def test_invitation_accept_rejects_invalid_bearer_as_authentication_failure(workspace_api): async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
_, client, _, _ = 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( response = await client.post(
'/api/v1/invitations/accept', '/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'}, '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( forbidden = await client.post(
'/api/v1/apikeys', '/api/v1/apikeys',
headers=_auth(viewer_token, workspace_uuid), headers=_auth(viewer_token, workspace_uuid),
@@ -382,6 +410,7 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in
workspace_api, workspace_api,
): ):
application, client, engine, owner_token = workspace_api application, client, engine, owner_token = workspace_api
application.deployment = SimpleNamespace(mode='cloud')
owner_uuid = jwt.decode( owner_uuid = jwt.decode(
owner_token, owner_token,
'workspace-api-secret', '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'}, 'registration': {'email': 'member@example.com', 'password': 'member-password'},
}, },
) )
assert registration_response.status_code == 409 assert registration_response.status_code == 401
assert (await registration_response.get_json())['code'] == 'control_plane_required' assert (await registration_response.get_json())['code'] == 'account_exists_login_required'
async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api): async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api):
@@ -24,7 +24,11 @@ from langbot.pkg.api.http.service.user import (
UserService, UserService,
) )
from langbot.pkg.entity.persistence.user import AccountSource, AccountStatus, User 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 pytestmark = pytest.mark.asyncio
@@ -97,6 +101,7 @@ def _create_mock_user(
"""Helper to create mock User entity.""" """Helper to create mock User entity."""
user = Mock(spec=User) user = Mock(spec=User)
user.user = email user.user = email
user.uuid = f'account-{email}'
user.password = password user.password = password
user.account_type = account_type user.account_type = account_type
user.space_account_uuid = space_account_uuid user.space_account_uuid = space_account_uuid
@@ -694,8 +699,8 @@ class TestUserServiceCreateOrUpdateSpaceUser:
# Verify # Verify
assert result.space_account_uuid == 'new-space-uuid' assert result.space_account_uuid == 'new-space-uuid'
async def test_create_or_update_space_user_already_initialized_raises_error(self): async def test_create_or_update_space_user_already_initialized_reports_unknown_space_email(self):
"""Raises AccountEmailMismatchError when system already initialized and user not found.""" """Unknown Space email is distinct from an existing local Account collision."""
# Setup # Setup
ap = SimpleNamespace() ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
@@ -710,7 +715,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
service.is_initialized = AsyncMock(return_value=True) # Already initialized service.is_initialized = AsyncMock(return_value=True) # Already initialized
# Execute & Verify # Execute & Verify
with pytest.raises(AccountEmailMismatchError): with pytest.raises(SpaceAccountNotRegisteredError):
await service.create_or_update_space_user( await service.create_or_update_space_user(
space_account_uuid='unknown-space-uuid', space_account_uuid='unknown-space-uuid',
email='unknown@example.com', email='unknown@example.com',
@@ -747,7 +752,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
service.get_user_by_email = AsyncMock(return_value=existing_user) service.get_user_by_email = AsyncMock(return_value=existing_user)
service.generate_jwt_token = AsyncMock(return_value='must-not-be-issued') service.generate_jwt_token = AsyncMock(return_value='must-not-be-issued')
with pytest.raises(AccountEmailMismatchError): with pytest.raises(SpaceAccountBindingRequiredError):
await service.authenticate_space_user( await service.authenticate_space_user(
'attacker-access-token', 'attacker-access-token',
'attacker-refresh-token', 'attacker-refresh-token',
@@ -758,6 +763,46 @@ class TestUserServiceCreateOrUpdateSpaceUser:
ap.provider_service.update_space_model_provider_api_keys.assert_not_awaited() ap.provider_service.update_space_model_provider_api_keys.assert_not_awaited()
service.generate_jwt_token.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): async def test_create_or_update_space_user_no_expiry(self):
"""Creates Space user without token expiry.""" """Creates Space user without token expiry."""
# Setup # Setup
@@ -805,6 +850,49 @@ class TestUserServiceCreateOrUpdateSpaceUser:
assert result.space_account_uuid == 'noexpiry-uuid' 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: class TestUserServiceCreateUserLock:
"""Tests for create_user_lock attribute.""" """Tests for create_user_lock attribute."""
+22 -5
View File
@@ -68,6 +68,9 @@ function SpaceOAuthCallbackContent() {
'loading' | 'confirm' | 'success' | 'error' 'loading' | 'confirm' | 'success' | 'error'
>('loading'); >('loading');
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [terminalErrorCode, setTerminalErrorCode] = useState<
'space_account_not_registered' | 'space_account_binding_required' | null
>(null);
const [isBindMode, setIsBindMode] = useState(false); const [isBindMode, setIsBindMode] = useState(false);
const [code, setCode] = useState<string | null>(null); const [code, setCode] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
@@ -125,7 +128,15 @@ function SpaceOAuthCallbackContent() {
} }
setStatus('error'); 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(); const errMsg = (errorObj?.msg || '').toLowerCase();
if (errMsg.includes('account email mismatch')) { if (errMsg.includes('account email mismatch')) {
setErrorMessage(t('account.spaceEmailMismatch')); setErrorMessage(t('account.spaceEmailMismatch'));
@@ -168,7 +179,11 @@ function SpaceOAuthCallbackContent() {
} }
setStatus('error'); 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(); const errMsg = (errorObj?.msg || '').toLowerCase();
if (errMsg.includes('account email mismatch')) { if (errMsg.includes('account email mismatch')) {
setErrorMessage(t('account.spaceEmailMismatch')); setErrorMessage(t('account.spaceEmailMismatch'));
@@ -278,9 +293,11 @@ function SpaceOAuthCallbackContent() {
? t('account.bindSpaceSuccess') ? t('account.bindSpaceSuccess')
: t('common.spaceLoginSuccess'))} : t('common.spaceLoginSuccess'))}
{status === 'error' && {status === 'error' &&
(isBindMode (terminalErrorCode
? t('account.bindSpaceFailed') ? t(`account.${terminalErrorCode}Title`)
: t('common.spaceLoginError'))} : isBindMode
? t('account.bindSpaceFailed')
: t('common.spaceLoginError'))}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{status === 'loading' && {status === 'loading' &&
@@ -25,6 +25,7 @@ import {
import { CustomApiError } from '@/app/infra/entities/common'; import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout'; import { PanelBody } from '../settings-dialog/panel-layout';
import { useCurrentWorkspace } from '@/app/infra/http'; import { useCurrentWorkspace } from '@/app/infra/http';
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
interface ModelsPanelProps { interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open. // 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; currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
const [providers, setProviders] = useState<ModelProvider[]>([]); const [providers, setProviders] = useState<ModelProvider[]>([]);
const [accountType, setAccountType] = useState<'local' | 'space'>('local'); const [spaceBilling, setSpaceBilling] =
const [spaceCredits, setSpaceCredits] = useState<number | null>(null); useState<WorkspaceSpaceBilling | null>(null);
// Expanded providers and their models // Expanded providers and their models
const [expandedProviders, setExpandedProviders] = useState<Set<string>>( const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
@@ -144,7 +145,7 @@ export default function ModelsPanel({
useEffect(() => { useEffect(() => {
if (active) { if (active) {
loadUserInfo(); loadWorkspaceBilling();
loadProviders(); loadProviders();
loadRequesterSupportTypes(); loadRequesterSupportTypes();
} }
@@ -167,16 +168,11 @@ export default function ModelsPanel({
} }
}, [providersLoaded, providers]); }, [providersLoaded, providers]);
async function loadUserInfo() { async function loadWorkspaceBilling() {
try { try {
const userInfo = await httpClient.getUserInfo(); setSpaceBilling(await httpClient.getWorkspaceSpaceBilling());
setAccountType(userInfo.account_type);
if (userInfo.account_type === 'space') {
const creditsInfo = await httpClient.getSpaceCredits();
setSpaceCredits(creditsInfo.credits);
}
} catch { } catch {
setAccountType('local'); setSpaceBilling(null);
} }
} }
@@ -546,8 +542,9 @@ export default function ModelsPanel({
isExpanded={expandedProviders.has(provider.uuid)} isExpanded={expandedProviders.has(provider.uuid)}
isLoading={loadingProviders.has(provider.uuid)} isLoading={loadingProviders.has(provider.uuid)}
models={providerModels[provider.uuid]} models={providerModels[provider.uuid]}
accountType={accountType} isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
spaceCredits={spaceCredits} ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
spaceCredits={spaceBilling?.credits ?? null}
addModelPopoverOpen={addModelPopoverOpen} addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen} editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen} deleteConfirmOpen={deleteConfirmOpen}
@@ -44,7 +44,8 @@ interface ProviderCardProps {
isExpanded: boolean; isExpanded: boolean;
isLoading: boolean; isLoading: boolean;
models?: ProviderModels; models?: ProviderModels;
accountType: 'local' | 'space'; isWorkspaceOwner: boolean;
ownerSpaceBound: boolean;
spaceCredits: number | null; spaceCredits: number | null;
// Popover states // Popover states
addModelPopoverOpen: string | null; addModelPopoverOpen: string | null;
@@ -108,7 +109,8 @@ export default function ProviderCard({
isExpanded, isExpanded,
isLoading, isLoading,
models, models,
accountType, isWorkspaceOwner,
ownerSpaceBound,
spaceCredits, spaceCredits,
addModelPopoverOpen, addModelPopoverOpen,
editModelPopoverOpen, editModelPopoverOpen,
@@ -198,7 +200,7 @@ export default function ProviderCard({
</div> </div>
</div> </div>
<div className="flex items-center gap-1 ml-2 shrink-0"> <div className="flex items-center gap-1 ml-2 shrink-0">
{canManage && isLangBotModels && accountType !== 'space' && ( {isLangBotModels && isWorkspaceOwner && !ownerSpaceBound && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -208,32 +210,40 @@ export default function ProviderCard({
}} }}
> >
<LogIn className="h-4 w-4 mr-1" /> <LogIn className="h-4 w-4 mr-1" />
{t('models.loginWithSpace')} {t('models.ownerMustBindSpace')}
</Button> </Button>
)} )}
{isLangBotModels && {isLangBotModels && ownerSpaceBound && spaceCredits !== null && (
accountType === 'space' && <div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
spaceCredits !== null && ( <span>
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2"> {(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
<span> </span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')} <Button
</span> variant="ghost"
<Button size="icon"
variant="ghost" className="h-5 w-5"
size="icon" onClick={(e) => {
className="h-5 w-5" e.stopPropagation();
onClick={(e) => { window.open(
e.stopPropagation(); `${systemInfo.cloud_service_url}/profile?tab=billing`,
window.open( '_blank',
`${systemInfo.cloud_service_url}/profile?tab=billing`, );
'_blank', }}
); >
}} <Plus className="h-3 w-3" />
> </Button>
<Plus className="h-3 w-3" /> </div>
</Button> )}
</div> {isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
)} <span className="text-xs text-muted-foreground">
{t('models.usesOwnerSpaceBilling')}
</span>
)}
{isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && (
<span className="text-xs text-muted-foreground">
{t('models.ownerMustBindSpace')}
</span>
)}
{canManage && !isLangBotModels && ( {canManage && !isLangBotModels && (
<> <>
<Button <Button
+6
View File
@@ -35,6 +35,12 @@ export interface CurrentWorkspace {
plan_name?: string | null; plan_name?: string | null;
} }
export interface WorkspaceSpaceBilling {
credits: number | null;
owner_space_bound: boolean;
is_workspace_owner: boolean;
}
/** Account-scoped Workspace entry returned before a Workspace is selected. */ /** Account-scoped Workspace entry returned before a Workspace is selected. */
export type WorkspaceBootstrapEntry = CurrentWorkspace; export type WorkspaceBootstrapEntry = CurrentWorkspace;
+3 -4
View File
@@ -69,6 +69,7 @@ import type {
WorkspaceMembership, WorkspaceMembership,
WorkspaceBootstrapResponse, WorkspaceBootstrapResponse,
WorkspaceRole, WorkspaceRole,
WorkspaceSpaceBilling,
} from '@/app/infra/entities/workspace'; } from '@/app/infra/entities/workspace';
/** /**
@@ -1146,10 +1147,8 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/user/info', undefined, { skipWorkspace: true }); return this.get('/api/v1/user/info', undefined, { skipWorkspace: true });
} }
public getSpaceCredits(): Promise<{ credits: number | null }> { public getWorkspaceSpaceBilling(): Promise<WorkspaceSpaceBilling> {
return this.get('/api/v1/user/space-credits', undefined, { return this.get('/api/v1/user/space-credits');
skipWorkspace: true,
});
} }
public getAccountInfo(): Promise<{ public getAccountInfo(): Promise<{
+13 -27
View File
@@ -22,7 +22,6 @@ import type {
} from '@/app/infra/entities/workspace'; } from '@/app/infra/entities/workspace';
import { import {
backendClient, backendClient,
beginAuthenticatedSession,
bootstrapWorkspaceSession, bootstrapWorkspaceSession,
clearPendingInvitationToken, clearPendingInvitationToken,
clearUserInfo, clearUserInfo,
@@ -168,10 +167,12 @@ export default function AcceptInvitationPage() {
token, token,
registration, registration,
); );
beginAuthenticatedSession( if (registration) {
response.token, clearPendingInvitationToken();
registration?.email ?? view?.invitation.normalized_email, toast.success(t('workspace.invitationAccepted'));
); navigate('/login?invitation=1', { replace: true });
return;
}
clearPendingInvitationToken(); clearPendingInvitationToken();
const workspaceResult = await bootstrapWorkspaceSession({ const workspaceResult = await bootstrapWorkspaceSession({
preferredWorkspaceUuid: response.workspace_uuid, preferredWorkspaceUuid: response.workspace_uuid,
@@ -218,13 +219,14 @@ export default function AcceptInvitationPage() {
}); });
} }
function switchAccount() { function logoutAndReturn() {
if (token) setPendingInvitationToken(token);
clearUserInfo(); clearUserInfo();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('userEmail'); localStorage.removeItem('userEmail');
} }
navigate('/login?invitation=1&auto=space', { replace: true }); navigate('/login?invitation=1', { replace: true });
} }
function returnToLogin() { function returnToLogin() {
@@ -238,11 +240,6 @@ export default function AcceptInvitationPage() {
const hasLoginToken = const hasLoginToken =
typeof window !== 'undefined' && Boolean(localStorage.getItem('token')); typeof window !== 'undefined' && Boolean(localStorage.getItem('token'));
const currentEmail =
typeof window !== 'undefined' ? localStorage.getItem('userEmail') : null;
const currentAccountMatches =
currentEmail?.trim().toLocaleLowerCase() ===
view?.invitation.normalized_email.toLocaleLowerCase();
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900"> <div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900">
@@ -307,24 +304,13 @@ export default function AcceptInvitationPage() {
</div> </div>
)} )}
{hasLoginToken && currentAccountMatches ? ( {hasLoginToken ? (
<Button
className="w-full"
disabled={status === 'submitting'}
onClick={() => void finishAcceptance()}
>
{status === 'submitting' && (
<Loader2 className="size-4 animate-spin" />
)}
{t('workspace.acceptAsCurrentAccount')}
</Button>
) : hasLoginToken ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100"> <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
{t('workspace.invitationEmailMismatch')} {t('workspace.authenticatedInvitationNotice')}
</div> </div>
<Button className="w-full" onClick={switchAccount}> <Button className="w-full" onClick={logoutAndReturn}>
{t('workspace.switchAccount')} {t('workspace.logoutAndReturn')}
</Button> </Button>
</div> </div>
) : passwordRegistrationEnabled ? ( ) : passwordRegistrationEnabled ? (
+14 -1
View File
@@ -279,6 +279,10 @@ const enUS = {
credits: 'Credits', credits: 'Credits',
loginWithSpace: 'Login with LangBot Account', loginWithSpace: 'Login with LangBot Account',
loginToUseModels: 'Login with Space to use cloud models', 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', noModels: 'No models configured',
langbotModels: 'LangBot Models', langbotModels: 'LangBot Models',
spaceTrialTooltip: spaceTrialTooltip:
@@ -1283,7 +1287,13 @@ const enUS = {
'Invalid bind request. Please try again from account settings.', 'Invalid bind request. Please try again from account settings.',
setPasswordHint: 'Set a password to login with email and password', setPasswordHint: 'Set a password to login with email and password',
spaceEmailMismatch: 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: { workspace: {
title: 'Workspace', title: 'Workspace',
@@ -1339,6 +1349,9 @@ const enUS = {
existingAccountLoginRequired: existingAccountLoginRequired:
'An account already exists for this email. Sign in to continue.', 'An account already exists for this email. Sign in to continue.',
acceptAsCurrentAccount: 'Accept with current account', 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', switchAccount: 'Switch account',
registerAndAccept: 'Create account and accept', registerAndAccept: 'Create account and accept',
alreadyHaveAccount: 'I already have an account', alreadyHaveAccount: 'I already have an account',
+13
View File
@@ -284,6 +284,10 @@ const jaJP = {
credits: 'クレジット', credits: 'クレジット',
loginWithSpace: 'LangBot アカウントでログイン', loginWithSpace: 'LangBot アカウントでログイン',
loginToUseModels: 'Space でログインしてクラウドモデルを使用', loginToUseModels: 'Space でログインしてクラウドモデルを使用',
ownerMustBindSpace:
'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。',
usesOwnerSpaceBilling:
'ワークスペース所有者の Space 課金とクレジットを使用します。',
noModels: 'モデルがありません', noModels: 'モデルがありません',
langbotModels: 'LangBot モデル', langbotModels: 'LangBot モデル',
spaceTrialTooltip: spaceTrialTooltip:
@@ -1289,6 +1293,12 @@ const jaJP = {
'パスワードを設定するとメールとパスワードでログインできます', 'パスワードを設定するとメールとパスワードでログインできます',
spaceEmailMismatch: spaceEmailMismatch:
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません', 'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
space_account_not_registeredTitle: 'アカウントが登録されていません',
space_account_not_registered:
'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
space_account_binding_requiredTitle: 'Space の連携が必要です',
space_account_binding_required:
'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。',
}, },
workspace: { workspace: {
title: 'ワークスペース', title: 'ワークスペース',
@@ -1340,6 +1350,9 @@ const jaJP = {
existingAccountLoginRequired: existingAccountLoginRequired:
'このメールアドレスのアカウントは既に存在します。ログインしてください。', 'このメールアドレスのアカウントは既に存在します。ログインしてください。',
acceptAsCurrentAccount: '現在のアカウントで承認', acceptAsCurrentAccount: '現在のアカウントで承認',
authenticatedInvitationNotice:
'一度ログアウトし、招待されたアカウントでログインしてください。招待は保持されます。',
logoutAndReturn: 'ログアウトしてこの招待に戻る',
switchAccount: 'アカウントを切り替える', switchAccount: 'アカウントを切り替える',
registerAndAccept: 'アカウントを作成して承認', registerAndAccept: 'アカウントを作成して承認',
alreadyHaveAccount: 'アカウントを持っています', alreadyHaveAccount: 'アカウントを持っています',
+11
View File
@@ -267,6 +267,8 @@ const zhHans = {
credits: '积分', credits: '积分',
loginWithSpace: '使用 LangBot 账号登录', loginWithSpace: '使用 LangBot 账号登录',
loginToUseModels: '通过 Space 登录以使用云端模型', loginToUseModels: '通过 Space 登录以使用云端模型',
ownerMustBindSpace: '工作区所有者需要绑定 Space 才能使用 LangBot 模型。',
usesOwnerSpaceBilling: '使用工作区所有者的 Space 计费与积分。',
noModels: '暂无模型', noModels: '暂无模型',
langbotModels: 'LangBot 模型', langbotModels: 'LangBot 模型',
spaceTrialTooltip: spaceTrialTooltip:
@@ -1219,6 +1221,12 @@ const zhHans = {
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起', bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录', setPasswordHint: '设置密码后可使用邮箱密码登录',
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配', spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
space_account_not_registeredTitle: '账户尚未注册',
space_account_not_registered:
'此 Space 邮箱尚无本地账户,请联系工作区所有者获取邀请。',
space_account_binding_requiredTitle: '需要绑定 Space',
space_account_binding_required:
'此本地账户必须先在账户设置中绑定 Space,才能使用 Space 登录。',
}, },
workspace: { workspace: {
title: '工作区', title: '工作区',
@@ -1270,6 +1278,9 @@ const zhHans = {
invitationEmailMismatch: '此邀请属于另一个邮箱地址。', invitationEmailMismatch: '此邀请属于另一个邮箱地址。',
existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。', existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。',
acceptAsCurrentAccount: '使用当前账户接受', acceptAsCurrentAccount: '使用当前账户接受',
authenticatedInvitationNotice:
'请先退出,再使用受邀账户登录。邀请令牌会被保留。',
logoutAndReturn: '退出并返回此邀请',
switchAccount: '切换账号', switchAccount: '切换账号',
registerAndAccept: '创建账户并接受', registerAndAccept: '创建账户并接受',
alreadyHaveAccount: '我已有账户', alreadyHaveAccount: '我已有账户',
@@ -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/);
});