diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 6b42d4ea3..5568047ca 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -322,6 +322,7 @@ class UserRouterGroup(group.RouterGroup): if cloud_mode: capabilities['password_login_enabled'] = False capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode + capabilities['invitation_registration_enabled'] = not cloud_mode return self.success(data={'initialized': True, **capabilities}) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index dfd5054f2..642efaf2b 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -307,6 +307,7 @@ class TestUserInitEndpoint: assert data['data'] == { 'initialized': True, 'authenticated_invitation_acceptance_enabled': False, + 'invitation_registration_enabled': True, 'password_login_enabled': True, 'space_login_enabled': False, } @@ -330,6 +331,28 @@ class TestUserInitEndpoint: assert data['data'] == { 'initialized': True, 'authenticated_invitation_acceptance_enabled': True, + 'invitation_registration_enabled': False, + 'password_login_enabled': False, + 'space_login_enabled': True, + } + + @pytest.mark.asyncio + async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss( + 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': False, 'space_login_enabled': True} + ) + + response = await quart_test_client.get('/api/v1/user/account-info') + + assert response.status_code == 200 + data = await response.get_json() + assert data['data'] == { + 'initialized': True, + 'authenticated_invitation_acceptance_enabled': False, + 'invitation_registration_enabled': True, 'password_login_enabled': False, 'space_login_enabled': True, } diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 9d22e5b3c..be0a9f021 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -312,6 +312,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): application.space_service.get_credits.assert_awaited_once_with('owner@example.com') +@pytest.mark.asyncio +async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(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=None) + ) + application.space_service.get_credits = AsyncMock() + + response = await client.get( + '/api/v1/user/space-credits', + headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, + ) + payload = await response.get_json() + + assert response.status_code == 200 + assert payload['data'] == { + 'credits': None, + 'owner_space_bound': False, + 'is_workspace_owner': True, + } + application.space_service.get_credits.assert_not_awaited() + + @pytest.mark.asyncio async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api): application, client = space_oauth_api diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 1bfe1fcf2..07df4bf7c 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1181,6 +1181,7 @@ export class BackendClient extends BaseHttpClient { public getAccountInfo(): Promise<{ initialized: boolean; authenticated_invitation_acceptance_enabled?: boolean; + invitation_registration_enabled?: boolean; password_login_enabled?: boolean; space_login_enabled?: boolean; }> { diff --git a/web/src/app/invitations/accept/page.tsx b/web/src/app/invitations/accept/page.tsx index 0d83331b5..1d9591a54 100644 --- a/web/src/app/invitations/accept/page.tsx +++ b/web/src/app/invitations/accept/page.tsx @@ -91,7 +91,9 @@ export default function AcceptInvitationPage() { const [errorMessage, setErrorMessage] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = + const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] = + useState(false); + const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] = useState(false); const [ authenticatedInvitationAcceptanceEnabled, @@ -116,12 +118,16 @@ export default function AcceptInvitationPage() { backendClient .getAccountInfo() .then((info) => { - setPasswordRegistrationEnabled(info.password_login_enabled !== false); + setInvitationRegistrationEnabled( + info.invitation_registration_enabled ?? + info.password_login_enabled !== false, + ); setAuthenticatedInvitationAcceptanceEnabled( info.authenticated_invitation_acceptance_enabled === true, ); }) - .catch(() => setPasswordRegistrationEnabled(false)); + .catch(() => setInvitationRegistrationEnabled(false)) + .finally(() => setInvitationCapabilitiesLoaded(true)); if (!invitationToken) { setErrorMessage(t('workspace.invitationMissing')); setStatus('error'); @@ -311,7 +317,11 @@ export default function AcceptInvitationPage() { )} - {hasLoginToken && authenticatedInvitationAcceptanceEnabled ? ( + {!invitationCapabilitiesLoaded ? ( +
+ +
+ ) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (