mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
274 lines
9.3 KiB
TypeScript
274 lines
9.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
HardDrive,
|
|
KeyRound,
|
|
Settings,
|
|
Sparkles,
|
|
UsersRound,
|
|
} from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarProvider,
|
|
} from '@/components/ui/sidebar';
|
|
import { cn } from '@/lib/utils';
|
|
import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/AccountSettingsPanel';
|
|
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
|
|
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
|
|
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
|
|
import WorkspaceSettingsPanel from '@/app/home/components/workspace-settings/WorkspaceSettingsPanel';
|
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
|
|
|
// The set of settings sections shown in the unified dialog. The string values
|
|
// are also reused as the ?action= query param suffix so deep links keep working.
|
|
export type SettingsSection =
|
|
| 'workspace'
|
|
| 'account'
|
|
| 'apiIntegration'
|
|
| 'models'
|
|
| 'storageAnalysis';
|
|
|
|
// Map between a section id and its ?action= query value, so existing deep links
|
|
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
|
|
// showStorageAnalysis) continue to resolve to the right section.
|
|
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
|
|
workspace: 'showWorkspaceSettings',
|
|
account: 'showAccountSettings',
|
|
apiIntegration: 'showApiIntegrationSettings',
|
|
models: 'showModelSettings',
|
|
storageAnalysis: 'showStorageAnalysis',
|
|
};
|
|
|
|
export const SETTINGS_SECTION_BY_ACTION: Record<string, SettingsSection> =
|
|
Object.fromEntries(
|
|
Object.entries(SETTINGS_ACTION_BY_SECTION).map(([section, action]) => [
|
|
action,
|
|
section as SettingsSection,
|
|
]),
|
|
);
|
|
|
|
interface SettingsDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
section: SettingsSection;
|
|
onSectionChange: (section: SettingsSection) => void;
|
|
}
|
|
|
|
export default function SettingsDialog({
|
|
open,
|
|
onOpenChange,
|
|
section,
|
|
onSectionChange,
|
|
}: SettingsDialogProps) {
|
|
const { t } = useTranslation();
|
|
const currentWorkspace = useCurrentWorkspace();
|
|
// A nested modal (e.g. the provider form) can request that we ignore
|
|
// outer-close until it is dismissed.
|
|
const [blocking, setBlocking] = useState(false);
|
|
|
|
// Only the Models panel can raise a blocking nested modal. When we navigate
|
|
// away from it (or close the dialog) the panel unmounts without resetting,
|
|
// so clear the flag here to avoid getting stuck unable to close.
|
|
useEffect(() => {
|
|
if (section !== 'models' || !open) {
|
|
setBlocking(false);
|
|
}
|
|
}, [section, open]);
|
|
|
|
const allNavItems: {
|
|
id: SettingsSection;
|
|
label: string;
|
|
title: string;
|
|
description: string;
|
|
icon: React.ReactNode;
|
|
}[] = [
|
|
{
|
|
id: 'workspace',
|
|
label: t('settingsDialog.nav.workspace'),
|
|
title: t('workspace.title'),
|
|
description: t('workspace.description'),
|
|
icon: <UsersRound className="size-4" />,
|
|
},
|
|
{
|
|
id: 'models',
|
|
label: t('settingsDialog.nav.models'),
|
|
title: t('models.title'),
|
|
description: t('models.description'),
|
|
icon: <Sparkles className="size-4" />,
|
|
},
|
|
{
|
|
id: 'apiIntegration',
|
|
label: t('settingsDialog.nav.api'),
|
|
title: t('common.apiIntegration'),
|
|
description: t('common.apiIntegrationDescription'),
|
|
icon: <KeyRound className="size-4" />,
|
|
},
|
|
{
|
|
id: 'storageAnalysis',
|
|
label: t('settingsDialog.nav.storage'),
|
|
title: t('storageAnalysis.title'),
|
|
description: t('storageAnalysis.description'),
|
|
icon: <HardDrive className="size-4" />,
|
|
},
|
|
{
|
|
id: 'account',
|
|
label: t('settingsDialog.nav.account'),
|
|
title: t('account.settings'),
|
|
description: t('account.settingsDescription'),
|
|
icon: <Settings className="size-4" />,
|
|
},
|
|
];
|
|
const permissions = currentWorkspace?.permissions ?? [];
|
|
const canManageApiKeys = permissions.includes('api_key.manage');
|
|
const canViewAudit = permissions.includes('audit.view');
|
|
const navItems = allNavItems.filter((item) => {
|
|
if (item.id === 'apiIntegration') {
|
|
return canManageApiKeys;
|
|
}
|
|
if (item.id === 'storageAnalysis') {
|
|
return canViewAudit;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
useEffect(() => {
|
|
const forbiddenSection =
|
|
(section === 'apiIntegration' && !canManageApiKeys) ||
|
|
(section === 'storageAnalysis' && !canViewAudit);
|
|
if (open && forbiddenSection) {
|
|
onSectionChange('workspace');
|
|
}
|
|
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]);
|
|
|
|
const activeItem = navItems.find((item) => item.id === section);
|
|
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(newOpen) => {
|
|
if (!newOpen && blocking) return;
|
|
onOpenChange(newOpen);
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className="h-[80vh] max-h-[800px] overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
|
|
// Fixed height so switching sections never resizes the dialog; each
|
|
// panel scrolls its own content internally.
|
|
>
|
|
<DialogTitle className="sr-only">
|
|
{t('settingsDialog.title')}
|
|
</DialogTitle>
|
|
<DialogDescription className="sr-only">{activeLabel}</DialogDescription>
|
|
|
|
{/* Override the SidebarProvider wrapper's default h-svh so the two
|
|
columns fill the dialog's fixed height instead of the viewport. */}
|
|
<SidebarProvider className="!min-h-0 h-full">
|
|
<Sidebar
|
|
collapsible="none"
|
|
className="hidden h-full w-44 shrink-0 border-r md:flex"
|
|
>
|
|
<SidebarContent>
|
|
<SidebarGroup>
|
|
<SidebarGroupContent>
|
|
<div className="px-2 py-3 text-sm font-semibold">
|
|
{t('settingsDialog.title')}
|
|
</div>
|
|
<SidebarMenu>
|
|
{navItems.map((item) => (
|
|
<SidebarMenuItem key={item.id}>
|
|
<SidebarMenuButton
|
|
isActive={section === item.id}
|
|
onClick={() => onSectionChange(item.id)}
|
|
>
|
|
{item.icon}
|
|
<span>{item.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
</SidebarContent>
|
|
</Sidebar>
|
|
|
|
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
|
|
{/* Mobile section switcher (sidebar is hidden on small screens) */}
|
|
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
|
|
{navItems.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => onSectionChange(item.id)}
|
|
className={cn(
|
|
'flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm',
|
|
section === item.id
|
|
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
: 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{item.icon}
|
|
<span>{item.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Unified section header (shared across all tabs). The extra
|
|
right padding keeps the title clear of the dialog's close X. */}
|
|
<div className="flex shrink-0 flex-col gap-0.5 border-b px-6 py-4 pr-12">
|
|
<h2 className="flex items-center gap-2 text-base font-semibold">
|
|
{activeItem?.icon}
|
|
{activeItem?.title}
|
|
</h2>
|
|
{activeItem?.description && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{activeItem.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
{section === 'workspace' && (
|
|
<WorkspaceSettingsPanel
|
|
active={open && section === 'workspace'}
|
|
/>
|
|
)}
|
|
{section === 'models' && (
|
|
<ModelsPanel
|
|
active={open && section === 'models'}
|
|
onBlockingChange={setBlocking}
|
|
/>
|
|
)}
|
|
{section === 'apiIntegration' && (
|
|
<ApiIntegrationPanel
|
|
active={open && section === 'apiIntegration'}
|
|
/>
|
|
)}
|
|
{section === 'storageAnalysis' && (
|
|
<StorageAnalysisPanel
|
|
active={open && section === 'storageAnalysis'}
|
|
/>
|
|
)}
|
|
{section === 'account' && (
|
|
<AccountSettingsPanel active={open && section === 'account'} />
|
|
)}
|
|
</div>
|
|
</main>
|
|
</SidebarProvider>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|