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