mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 06:16:09 +00:00
feat(cloud): complete Workspace settings navigation
This commit is contained in:
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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 */}
|
||||
<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 */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -2068,6 +2090,15 @@ export default function HomeSidebar({
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openSettings('workspace');
|
||||
}}
|
||||
>
|
||||
<UsersRound />
|
||||
{t('workspace.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
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 {
|
||||
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 (
|
||||
<Select
|
||||
value={currentWorkspace.workspace.uuid}
|
||||
onValueChange={switchWorkspaceAndReload}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className={cn('min-w-44 max-w-64', className)}
|
||||
aria-label={t('workspace.switchWorkspace')}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{workspaces.map((entry) => (
|
||||
<SelectItem key={entry.workspace.uuid} value={entry.workspace.uuid}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{entry.workspace.name}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('min-w-44 max-w-64 justify-start', className)}
|
||||
aria-label={t('workspace.switchWorkspace')}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
<span className="truncate">{currentWorkspace.workspace.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-64">
|
||||
<DropdownMenuLabel>{t('workspace.switchWorkspace')}</DropdownMenuLabel>
|
||||
{workspaces.map((entry) => {
|
||||
const selected =
|
||||
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">
|
||||
{t(`workspace.roles.${entry.membership.role}`)}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selected && <Check className="size-4" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const autoSpaceLoginStarted = useRef(false);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1227,6 +1227,10 @@ const zhHans = {
|
||||
selectDescription: '选择你要进入的 LangBot 工作区。',
|
||||
selectionLoadFailed: '无法加载你的工作区,请重试。',
|
||||
switchWorkspace: '切换工作区',
|
||||
settings: '工作区设置',
|
||||
currentPlan: '当前计划',
|
||||
planUnavailable: '暂不可用',
|
||||
upgradePlan: '切换或升级计划',
|
||||
ossSingletonDescription:
|
||||
'当前自托管实例只有一个工作区,但可以包含多个用户。',
|
||||
cloudManagedDescription:
|
||||
|
||||
Reference in New Issue
Block a user