feat(tenancy): implement workspace isolation

This commit is contained in:
Junyan Qin
2026-07-19 09:58:59 +08:00
parent 37099ddf7e
commit 8b7ce77cec
271 changed files with 31166 additions and 6513 deletions
@@ -4,7 +4,11 @@ import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClientSync } from '@/app/infra/http';
import {
clearUserInfo,
getCloudServiceClientSync,
useCurrentWorkspace,
} from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import {
Moon,
@@ -380,6 +384,11 @@ function NavItems({
const sidebarData = useSidebarData();
const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManageResources =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canOperateRuntime =
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
// Track which entity categories have their full list expanded
const [expandedLists, setExpandedLists] = useState<SidebarListExpansionState>(
loadListExpansionState,
@@ -513,6 +522,9 @@ function NavItems({
<>
{sectionItems.map((config) => {
if (!isEntityCategory(config.id)) {
if (config.id === 'add-extension' && !canManageResources) {
return null;
}
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
return (
<SidebarMenuItem key={config.id}>
@@ -552,7 +564,8 @@ function NavItems({
: sidebarData[entityKey];
const routePrefix = ENTITY_ROUTE_MAP[categoryId];
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(categoryId);
const canCreate = CREATABLE_CATEGORIES.includes(categoryId);
const canCreate =
canManageResources && CREATABLE_CATEGORIES.includes(categoryId);
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(categoryId);
const isPlugin = categoryId === 'plugins';
const isSkill = categoryId === 'skills';
@@ -800,6 +813,7 @@ function NavItems({
{itemIsPluginType && !item.debug && (
<PluginItemMenu
item={item}
canManage={canManageResources}
onUpdate={() => handlePluginUpdate(item)}
onDelete={() => handlePluginDelete(item)}
/>
@@ -1063,7 +1077,7 @@ function NavItems({
{config.name}
</span>
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{isExtensionsCategory && (
{isExtensionsCategory && canOperateRuntime && (
<button
type="button"
title={t('common.refresh', '刷新')}
@@ -1330,10 +1344,12 @@ function NavItems({
// Dropdown menu for plugin sidebar sub-items (shown on hover)
function PluginItemMenu({
item,
canManage,
onUpdate,
onDelete,
}: {
item: SidebarEntityItem;
canManage: boolean;
onUpdate: () => void;
onDelete: () => void;
}) {
@@ -1344,6 +1360,8 @@ function PluginItemMenu({
const isGithub = item.installSource === 'github';
const hasSourceLink = isMarketplace || isGithub;
if (!canManage && !hasSourceLink) return null;
function handleViewSource() {
const slashIdx = item.id.indexOf('/');
const author = slashIdx >= 0 ? item.id.substring(0, slashIdx) : '';
@@ -1384,7 +1402,7 @@ function PluginItemMenu({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
{isMarketplace && (
{canManage && isMarketplace && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -1413,16 +1431,18 @@ function PluginItemMenu({
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:text-red-600"
onClick={() => {
onDelete();
setOpen(false);
}}
>
<Trash className="size-4" />
<span>{t('plugins.delete')}</span>
</DropdownMenuItem>
{canManage && (
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:text-red-600"
onClick={() => {
onDelete();
setOpen(false);
}}
>
<Trash className="size-4" />
<span>{t('plugins.delete')}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -1612,6 +1632,7 @@ export default function HomeSidebar({
useState<Record<string, boolean>>(loadSectionState);
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
@@ -1678,10 +1699,6 @@ export default function HomeSidebar({
useEffect(() => {
initSelect();
if (!localStorage.getItem('token')) {
localStorage.setItem('token', 'test-token');
localStorage.setItem('userEmail', 'test@example.com');
}
const storedEmail = localStorage.getItem('userEmail');
if (storedEmail) {
@@ -1820,6 +1837,7 @@ export default function HomeSidebar({
}
function handleLogout() {
clearUserInfo();
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
window.location.href = '/login';
@@ -1948,18 +1966,20 @@ export default function HomeSidebar({
</SidebarMenuItem>
</SidebarMenu>
{/* API Integration entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => openSettings('apiIntegration')}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4 text-blue-500" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* API-key management is available only to authorized Workspace roles. */}
{currentWorkspace?.permissions.includes('api_key.manage') && (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => openSettings('apiIntegration')}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4 text-blue-500" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)}
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
<SidebarMenu>