mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-23 18:47:15 +00:00
feat(oss): enforce invitation account and owner billing flows
This commit is contained in:
@@ -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})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'アカウントを持っています',
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user