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:
|
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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1227,6 +1227,10 @@ const zhHans = {
|
|||||||
selectDescription: '选择你要进入的 LangBot 工作区。',
|
selectDescription: '选择你要进入的 LangBot 工作区。',
|
||||||
selectionLoadFailed: '无法加载你的工作区,请重试。',
|
selectionLoadFailed: '无法加载你的工作区,请重试。',
|
||||||
switchWorkspace: '切换工作区',
|
switchWorkspace: '切换工作区',
|
||||||
|
settings: '工作区设置',
|
||||||
|
currentPlan: '当前计划',
|
||||||
|
planUnavailable: '暂不可用',
|
||||||
|
upgradePlan: '切换或升级计划',
|
||||||
ossSingletonDescription:
|
ossSingletonDescription:
|
||||||
'当前自托管实例只有一个工作区,但可以包含多个用户。',
|
'当前自托管实例只有一个工作区,但可以包含多个用户。',
|
||||||
cloudManagedDescription:
|
cloudManagedDescription:
|
||||||
|
|||||||
Reference in New Issue
Block a user