diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 290eb5aef..4917d5db9 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -106,12 +106,21 @@ class WorkspacesRouterGroup(group.RouterGroup): if account is None: return self.http_status(401, 'invalid_authentication', 'Account not found') workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid) + plan_name: str | None = None + resolver = getattr(self.ap, 'entitlement_resolver', None) + if workspace.source == WorkspaceSource.CLOUD_PROJECTION.value and resolver is not None: + entitlement = await resolver.resolve( + workspace.uuid, + minimum_revision=request_context.entitlement_revision, + ) + plan_name = entitlement.plan_name return self.success( data={ 'workspace': _workspace_payload(workspace), 'membership': _membership_payload(membership, email=account.user), 'permissions': sorted(request_context.workspace.permissions), 'placement_generation': request_context.placement_generation, + 'plan_name': plan_name, } ) diff --git a/src/langbot/pkg/cloud/entitlements.py b/src/langbot/pkg/cloud/entitlements.py index febcc0e9b..3e9e7d134 100644 --- a/src/langbot/pkg/cloud/entitlements.py +++ b/src/langbot/pkg/cloud/entitlements.py @@ -18,10 +18,11 @@ class EntitlementUnavailableError(RuntimeError): class EntitlementSnapshot(pydantic.BaseModel): - """Plan-agnostic capability projection consumed by open-source Core. + """Capability projection consumed by open-source Core. - Product and billing names deliberately do not appear here. The closed - Control Plane maps subscriptions to these generic features and limits. + Admission and quota decisions use normalized features/limits rather than + product plan names. ``plan_name`` is signed display metadata for Cloud UI + only and must never drive authorization or quota enforcement. """ model_config = pydantic.ConfigDict(frozen=True, extra='forbid') @@ -34,6 +35,9 @@ class EntitlementSnapshot(pydantic.BaseModel): expires_at: int = pydantic.Field(gt=0) features: dict[str, bool] = pydantic.Field(default_factory=dict) limits: dict[str, int] = pydantic.Field(default_factory=dict) + # Signed display metadata for Cloud UI only. Admission and quota decisions + # must continue to use generic ``features`` and ``limits`` exclusively. + plan_name: str | None = pydantic.Field(default=None, min_length=1, max_length=128) @pydantic.field_validator('features') @classmethod diff --git a/src/langbot/pkg/workspace/collaboration.py b/src/langbot/pkg/workspace/collaboration.py index 3742ca4d6..a6a433392 100644 --- a/src/langbot/pkg/workspace/collaboration.py +++ b/src/langbot/pkg/workspace/collaboration.py @@ -281,6 +281,17 @@ class WorkspaceCollaborationService: *, session: AsyncSession | None = None, ) -> list[WorkspaceMemberView]: + if session is None: + current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)() + tenant_uow: typing.Any = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if current_session is None and callable(tenant_uow): + async with tenant_uow(workspace_uuid) as workspace_uow: + return await self.list_members( + workspace_uuid, + actor, + session=workspace_uow.session, + ) + async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]: await self._load_actor(active_session, workspace_uuid, actor) statement = ( diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 273137456..129bdbedc 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -468,6 +468,22 @@ async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_ cloud_workspace_uuid, } + class PlanResolver: + async def resolve(self, workspace_uuid: str, *, minimum_revision: int = 0): + from langbot.pkg.cloud.entitlements import EntitlementSnapshot + + assert workspace_uuid == cloud_workspace_uuid + return EntitlementSnapshot( + instance_uuid='instance-workspace-api', + workspace_uuid=workspace_uuid, + entitlement_revision=max(12, minimum_revision), + status='active', + not_before=1, + expires_at=4102444800, + plan_name='free', + ) + + application.entitlement_resolver = PlanResolver() current_response = await client.get( '/api/v1/workspaces/current', headers=_auth(owner_token, cloud_workspace_uuid), @@ -477,6 +493,7 @@ async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_ assert current['workspace']['uuid'] == cloud_workspace_uuid assert current['workspace']['source'] == 'cloud_projection' assert current['placement_generation'] == 12 + assert current['plan_name'] == 'free' create_workspace = await client.post( '/api/v1/workspaces', diff --git a/tests/unit_tests/workspace/test_workspace_collaboration.py b/tests/unit_tests/workspace/test_workspace_collaboration.py index 8020e7c41..2f657e0c4 100644 --- a/tests/unit_tests/workspace/test_workspace_collaboration.py +++ b/tests/unit_tests/workspace/test_workspace_collaboration.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import uuid +from contextlib import asynccontextmanager from types import SimpleNamespace import pytest @@ -209,6 +210,36 @@ async def test_workspace_selector_requires_membership(collaboration_context): await service.resolve_account_workspace(outsider.uuid, workspace.uuid) +async def test_cloud_member_listing_opens_workspace_uow_when_request_scope_has_closed( + collaboration_context, +): + service, _, session_factory, _, workspace, owner_membership = collaboration_context + entered_workspaces: list[str] = [] + + @asynccontextmanager + async def tenant_uow(workspace_uuid: str): + entered_workspaces.append(workspace_uuid) + async with session_factory.begin() as session: + yield SimpleNamespace(session=session) + + service.ap.persistence_mgr = SimpleNamespace( + mode=SimpleNamespace(value='cloud_runtime'), + current_session=lambda: None, + tenant_uow=tenant_uow, + require_current_session=lambda: (_ for _ in ()).throw( + AssertionError('list_members must open a Workspace UoW') + ), + get_db_engine=lambda: session_factory.kw['bind'], + ) + + members = await service.list_members(workspace.uuid, owner_membership) + + assert entered_workspaces == [workspace.uuid] + assert [(member.email, member.membership.role) for member in members] == [ + ('owner@example.com', 'owner') + ] + + async def test_cloud_directory_requires_explicit_selector_and_rejects_local_mutation( collaboration_context, ): diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index c9f64cdaf..ab947ee08 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -36,6 +36,7 @@ import { Server, Puzzle, RefreshCcw, + UsersRound, } from 'lucide-react'; import { useTheme } from '@/components/providers/theme-provider'; @@ -61,6 +62,9 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { LanguageSelector } from '@/components/ui/language-selector'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import WorkspaceSwitcher, { + OPEN_WORKSPACE_SETTINGS_EVENT, +} from '@/app/home/components/workspace-settings/WorkspaceSwitcher'; import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog'; import SettingsDialog, { SettingsSection, @@ -1676,6 +1680,19 @@ export default function HomeSidebar({ }); } + useEffect(() => { + const openWorkspaceSettings = () => openSettings('workspace'); + window.addEventListener( + OPEN_WORKSPACE_SETTINGS_EVENT, + openWorkspaceSettings, + ); + return () => + window.removeEventListener( + OPEN_WORKSPACE_SETTINGS_EVENT, + openWorkspaceSettings, + ); + }); + function handleSettingsSectionChange(section: SettingsSection) { setSettingsSection(section); const params = new URLSearchParams(searchParams.toString()); @@ -1953,6 +1970,11 @@ export default function HomeSidebar({ {/* Footer */} + {currentWorkspace?.workspace.source === 'cloud_projection' && ( +
+ +
+ )} {/* Models entry */} @@ -2068,6 +2090,15 @@ export default function HomeSidebar({ {t('account.settings')} + { + setUserMenuOpen(false); + openSettings('workspace'); + }} + > + + {t('workspace.settings')} + { setUserMenuOpen(false); diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx index b4469887a..0830e20d7 100644 --- a/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx +++ b/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx @@ -1,20 +1,35 @@ -import { Building2 } from 'lucide-react'; +import { + Building2, + Check, + ExternalLink, + Settings, + Sparkles, +} from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { switchWorkspaceAndReload, + systemInfo, useCurrentWorkspace, useWorkspaceBootstrap, } from '@/app/infra/http'; +import { Button } from '@/components/ui/button'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; +export const OPEN_WORKSPACE_SETTINGS_EVENT = 'langbot:open-workspace-settings'; + +export function requestWorkspaceSettings(): void { + window.dispatchEvent(new Event(OPEN_WORKSPACE_SETTINGS_EVENT)); +} + export default function WorkspaceSwitcher({ className, }: { @@ -23,34 +38,78 @@ export default function WorkspaceSwitcher({ const { t } = useTranslation(); const currentWorkspace = useCurrentWorkspace(); const workspaces = useWorkspaceBootstrap(); + const isCloud = currentWorkspace?.workspace.source === 'cloud_projection'; - if (!currentWorkspace || workspaces.length <= 1) return null; + if (!currentWorkspace || (!isCloud && workspaces.length <= 1)) return null; + + const cloudPortalUrl = `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(currentWorkspace.workspace.uuid)}`; return ( - + {selected && } + + ); + })} + + {isCloud && ( + <> + + + {t('workspace.currentPlan')} + + + {currentWorkspace.plan_name || t('workspace.planUnavailable')} + + + + + + {t('workspace.upgradePlan')} + + + + + )} + + + {t('workspace.settings')} + + + ); } diff --git a/web/src/app/infra/entities/workspace.ts b/web/src/app/infra/entities/workspace.ts index 9bd02f583..f3866882c 100644 --- a/web/src/app/infra/entities/workspace.ts +++ b/web/src/app/infra/entities/workspace.ts @@ -31,6 +31,8 @@ export interface CurrentWorkspace { membership: WorkspaceMembership; permissions: string[]; placement_generation: number; + /** Signed Cloud display metadata; never used for client-side authorization. */ + plan_name?: string | null; } /** Account-scoped Workspace entry returned before a Workspace is selected. */ diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 911b4b083..48436017f 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -19,7 +19,7 @@ import { FormLabel, FormMessage, } from '@/components/ui/form'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { beginAuthenticatedSession, bootstrapWorkspaceSession, @@ -66,6 +66,7 @@ export default function Login() { const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [retrying, setRetrying] = useState(false); + const autoSpaceLoginStarted = useRef(false); const form = useForm>>({ resolver: zodResolver(formSchema(t)), @@ -196,7 +197,7 @@ export default function Login() { }); } - const handleSpaceLoginClick = async () => { + const handleSpaceLoginClick = useCallback(async () => { setSpaceLoading(true); try { const currentOrigin = window.location.origin; @@ -207,7 +208,20 @@ export default function Login() { toast.error(t('common.spaceLoginFailed')); setSpaceLoading(false); } - }; + }, [t]); + + useEffect(() => { + if ( + loading || + !showSpaceLogin || + autoSpaceLoginStarted.current || + new URLSearchParams(window.location.search).get('auto') !== 'space' + ) { + return; + } + autoSpaceLoginStarted.current = true; + void handleSpaceLoginClick(); + }, [handleSpaceLoginClick, loading, showSpaceLogin]); if (loading) { return ( diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 40ace38e6..87c8d39f5 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1293,6 +1293,10 @@ const enUS = { selectionLoadFailed: 'Your Workspaces could not be loaded. Please try again.', switchWorkspace: 'Switch Workspace', + settings: 'Workspace Settings', + currentPlan: 'Current plan', + planUnavailable: 'Unavailable', + upgradePlan: 'Change or upgrade plan', ossSingletonDescription: 'This self-hosted instance has one Workspace and can include multiple users.', cloudManagedDescription: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 031d57c04..70314f2b2 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1227,6 +1227,10 @@ const zhHans = { selectDescription: '选择你要进入的 LangBot 工作区。', selectionLoadFailed: '无法加载你的工作区,请重试。', switchWorkspace: '切换工作区', + settings: '工作区设置', + currentPlan: '当前计划', + planUnavailable: '暂不可用', + upgradePlan: '切换或升级计划', ossSingletonDescription: '当前自托管实例只有一个工作区,但可以包含多个用户。', cloudManagedDescription: