feat(cloud): complete Workspace settings navigation

This commit is contained in:
dadachann
2026-07-25 13:51:37 +08:00
parent 64e772e32d
commit d3f08a90b1
11 changed files with 222 additions and 36 deletions
@@ -106,12 +106,21 @@ class WorkspacesRouterGroup(group.RouterGroup):
if account is None: if account is None:
return self.http_status(401, 'invalid_authentication', 'Account not found') return self.http_status(401, 'invalid_authentication', 'Account not found')
workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid) 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( return self.success(
data={ data={
'workspace': _workspace_payload(workspace), 'workspace': _workspace_payload(workspace),
'membership': _membership_payload(membership, email=account.user), 'membership': _membership_payload(membership, email=account.user),
'permissions': sorted(request_context.workspace.permissions), 'permissions': sorted(request_context.workspace.permissions),
'placement_generation': request_context.placement_generation, 'placement_generation': request_context.placement_generation,
'plan_name': plan_name,
} }
) )
+7 -3
View File
@@ -18,10 +18,11 @@ class EntitlementUnavailableError(RuntimeError):
class EntitlementSnapshot(pydantic.BaseModel): 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 Admission and quota decisions use normalized features/limits rather than
Control Plane maps subscriptions to these generic features and limits. 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') model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
@@ -34,6 +35,9 @@ class EntitlementSnapshot(pydantic.BaseModel):
expires_at: int = pydantic.Field(gt=0) expires_at: int = pydantic.Field(gt=0)
features: dict[str, bool] = pydantic.Field(default_factory=dict) features: dict[str, bool] = pydantic.Field(default_factory=dict)
limits: dict[str, int] = 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') @pydantic.field_validator('features')
@classmethod @classmethod
@@ -281,6 +281,17 @@ class WorkspaceCollaborationService:
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> list[WorkspaceMemberView]: ) -> 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]: async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
await self._load_actor(active_session, workspace_uuid, actor) await self._load_actor(active_session, workspace_uuid, actor)
statement = ( statement = (
+17
View File
@@ -468,6 +468,22 @@ async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_
cloud_workspace_uuid, 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( current_response = await client.get(
'/api/v1/workspaces/current', '/api/v1/workspaces/current',
headers=_auth(owner_token, cloud_workspace_uuid), 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']['uuid'] == cloud_workspace_uuid
assert current['workspace']['source'] == 'cloud_projection' assert current['workspace']['source'] == 'cloud_projection'
assert current['placement_generation'] == 12 assert current['placement_generation'] == 12
assert current['plan_name'] == 'free'
create_workspace = await client.post( create_workspace = await client.post(
'/api/v1/workspaces', '/api/v1/workspaces',
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import uuid import uuid
from contextlib import asynccontextmanager
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@@ -209,6 +210,36 @@ async def test_workspace_selector_requires_membership(collaboration_context):
await service.resolve_account_workspace(outsider.uuid, workspace.uuid) 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( async def test_cloud_directory_requires_explicit_selector_and_rejects_local_mutation(
collaboration_context, collaboration_context,
): ):
@@ -36,6 +36,7 @@ import {
Server, Server,
Puzzle, Puzzle,
RefreshCcw, RefreshCcw,
UsersRound,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider'; 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 { LanguageSelector } from '@/components/ui/language-selector';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; 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 NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
import SettingsDialog, { import SettingsDialog, {
SettingsSection, 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) { function handleSettingsSectionChange(section: SettingsSection) {
setSettingsSection(section); setSettingsSection(section);
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@@ -1953,6 +1970,11 @@ export default function HomeSidebar({
{/* Footer */} {/* Footer */}
<SidebarFooter> <SidebarFooter>
{currentWorkspace?.workspace.source === 'cloud_projection' && (
<div className="px-2 group-data-[collapsible=icon]:px-0">
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
</div>
)}
{/* Models entry */} {/* Models entry */}
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
@@ -2068,6 +2090,15 @@ export default function HomeSidebar({
<Settings /> <Settings />
{t('account.settings')} {t('account.settings')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
openSettings('workspace');
}}
>
<UsersRound />
{t('workspace.settings')}
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setUserMenuOpen(false); setUserMenuOpen(false);
@@ -1,20 +1,35 @@
import { Building2 } from 'lucide-react'; import {
Building2,
Check,
ExternalLink,
Settings,
Sparkles,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
switchWorkspaceAndReload, switchWorkspaceAndReload,
systemInfo,
useCurrentWorkspace, useCurrentWorkspace,
useWorkspaceBootstrap, useWorkspaceBootstrap,
} from '@/app/infra/http'; } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import { import {
Select, DropdownMenu,
SelectContent, DropdownMenuContent,
SelectItem, DropdownMenuItem,
SelectTrigger, DropdownMenuLabel,
SelectValue, DropdownMenuSeparator,
} from '@/components/ui/select'; DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils'; 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({ export default function WorkspaceSwitcher({
className, className,
}: { }: {
@@ -23,34 +38,78 @@ export default function WorkspaceSwitcher({
const { t } = useTranslation(); const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace(); const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap(); 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 ( return (
<Select <DropdownMenu>
value={currentWorkspace.workspace.uuid} <DropdownMenuTrigger asChild>
onValueChange={switchWorkspaceAndReload} <Button
> variant="outline"
<SelectTrigger size="sm"
size="sm" className={cn('min-w-44 max-w-64 justify-start', className)}
className={cn('min-w-44 max-w-64', className)} aria-label={t('workspace.switchWorkspace')}
aria-label={t('workspace.switchWorkspace')} >
> <Building2 className="size-4 shrink-0" />
<Building2 className="size-4 shrink-0" /> <span className="truncate">{currentWorkspace.workspace.name}</span>
<SelectValue /> </Button>
</SelectTrigger> </DropdownMenuTrigger>
<SelectContent align="end"> <DropdownMenuContent align="end" className="min-w-64">
{workspaces.map((entry) => ( <DropdownMenuLabel>{t('workspace.switchWorkspace')}</DropdownMenuLabel>
<SelectItem key={entry.workspace.uuid} value={entry.workspace.uuid}> {workspaces.map((entry) => {
<span className="flex min-w-0 items-center gap-2"> const selected =
<span className="truncate">{entry.workspace.name}</span> entry.workspace.uuid === currentWorkspace.workspace.uuid;
return (
<DropdownMenuItem
key={entry.workspace.uuid}
onClick={() => {
if (!selected)
void switchWorkspaceAndReload(entry.workspace.uuid);
}}
>
<Building2 className="size-4" />
<span className="min-w-0 flex-1 truncate">
{entry.workspace.name}
</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t(`workspace.roles.${entry.membership.role}`)} {t(`workspace.roles.${entry.membership.role}`)}
</span> </span>
</span> {selected && <Check className="size-4" />}
</SelectItem> </DropdownMenuItem>
))} );
</SelectContent> })}
</Select> <DropdownMenuSeparator />
{isCloud && (
<>
<DropdownMenuLabel className="flex items-center justify-between gap-3 font-normal">
<span className="text-muted-foreground">
{t('workspace.currentPlan')}
</span>
<span className="font-medium text-foreground">
{currentWorkspace.plan_name || t('workspace.planUnavailable')}
</span>
</DropdownMenuLabel>
<DropdownMenuItem asChild>
<a
href={cloudPortalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Sparkles className="size-4" />
{t('workspace.upgradePlan')}
<ExternalLink className="ml-auto size-3.5" />
</a>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={requestWorkspaceSettings}>
<Settings className="size-4" />
{t('workspace.settings')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
); );
} }
+2
View File
@@ -31,6 +31,8 @@ export interface CurrentWorkspace {
membership: WorkspaceMembership; membership: WorkspaceMembership;
permissions: string[]; permissions: string[];
placement_generation: number; 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. */ /** Account-scoped Workspace entry returned before a Workspace is selected. */
+17 -3
View File
@@ -19,7 +19,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
beginAuthenticatedSession, beginAuthenticatedSession,
bootstrapWorkspaceSession, bootstrapWorkspaceSession,
@@ -66,6 +66,7 @@ export default function Login() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false); const [retrying, setRetrying] = useState(false);
const autoSpaceLoginStarted = useRef(false);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({ const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)), resolver: zodResolver(formSchema(t)),
@@ -196,7 +197,7 @@ export default function Login() {
}); });
} }
const handleSpaceLoginClick = async () => { const handleSpaceLoginClick = useCallback(async () => {
setSpaceLoading(true); setSpaceLoading(true);
try { try {
const currentOrigin = window.location.origin; const currentOrigin = window.location.origin;
@@ -207,7 +208,20 @@ export default function Login() {
toast.error(t('common.spaceLoginFailed')); toast.error(t('common.spaceLoginFailed'));
setSpaceLoading(false); 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) { if (loading) {
return ( return (
+4
View File
@@ -1293,6 +1293,10 @@ const enUS = {
selectionLoadFailed: selectionLoadFailed:
'Your Workspaces could not be loaded. Please try again.', 'Your Workspaces could not be loaded. Please try again.',
switchWorkspace: 'Switch Workspace', switchWorkspace: 'Switch Workspace',
settings: 'Workspace Settings',
currentPlan: 'Current plan',
planUnavailable: 'Unavailable',
upgradePlan: 'Change or upgrade plan',
ossSingletonDescription: ossSingletonDescription:
'This self-hosted instance has one Workspace and can include multiple users.', 'This self-hosted instance has one Workspace and can include multiple users.',
cloudManagedDescription: cloudManagedDescription:
+4
View File
@@ -1227,6 +1227,10 @@ const zhHans = {
selectDescription: '选择你要进入的 LangBot 工作区。', selectDescription: '选择你要进入的 LangBot 工作区。',
selectionLoadFailed: '无法加载你的工作区,请重试。', selectionLoadFailed: '无法加载你的工作区,请重试。',
switchWorkspace: '切换工作区', switchWorkspace: '切换工作区',
settings: '工作区设置',
currentPlan: '当前计划',
planUnavailable: '暂不可用',
upgradePlan: '切换或升级计划',
ossSingletonDescription: ossSingletonDescription:
'当前自托管实例只有一个工作区,但可以包含多个用户。', '当前自托管实例只有一个工作区,但可以包含多个用户。',
cloudManagedDescription: cloudManagedDescription: