feat(tenancy): add Workspace multi-tenant foundation (#2353)

* 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>
This commit is contained in:
RockChinQ
2026-07-30 21:43:35 +08:00
committed by GitHub
parent 463b120923
commit e1ac5e0fc8
468 changed files with 78320 additions and 13137 deletions
+2 -1
View File
@@ -636,7 +636,8 @@ function AddExtensionContent() {
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
httpClient
.installPluginFromGithub(
selectedAsset.download_url,
selectedAsset.id,
selectedRelease.id,
githubOwner,
githubRepo,
selectedRelease.tag_name,
+94 -69
View File
@@ -29,11 +29,17 @@ import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canViewMonitoring =
currentWorkspace?.permissions.includes('resource.view') ?? false;
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
// Set breadcrumb entity name
@@ -131,19 +137,23 @@ export default function BotDetailContent({ id }: { id: string }) {
{/* Header */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
{canManage && (
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
/>
<fieldset className="contents" disabled={!canManage}>
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
/>
</fieldset>
</div>
</div>
</div>
@@ -164,6 +174,7 @@ export default function BotDetailContent({ id }: { id: string }) {
id="bot-enable-switch"
checked={botEnabled}
onCheckedChange={handleEnableToggle}
disabled={!canManage}
/>
<Label
htmlFor="bot-enable-switch"
@@ -174,14 +185,16 @@ export default function BotDetailContent({ id }: { id: string }) {
</div>
)}
</div>
<Button
type="submit"
form="bot-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
{canManage && (
<Button
type="submit"
form="bot-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
)}
</div>
{/* Horizontal Tabs */}
@@ -197,14 +210,18 @@ export default function BotDetailContent({ id }: { id: string }) {
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
</TabsTrigger>
{canViewMonitoring && (
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
)}
{canViewMonitoring && (
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
</TabsTrigger>
)}
</TabsList>
{activeTab === 'sessions' && (
<button
@@ -239,60 +256,68 @@ export default function BotDetailContent({ id }: { id: string }) {
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<BotForm
initBotId={id}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty}
/>
<fieldset className="contents" disabled={!canManage}>
<BotForm
initBotId={id}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty}
/>
</fieldset>
{/* Card: Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('bots.dangerZone')}
</CardTitle>
<CardDescription>
{t('bots.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('bots.deleteBotAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('bots.deleteBotHint')}
</p>
{canManage && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('bots.dangerZone')}
</CardTitle>
<CardDescription>
{t('bots.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('bots.deleteBotAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('bots.deleteBotHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
)}
</div>
</TabsContent>
{/* Tab: Logs */}
<TabsContent
value="logs"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<BotLogListComponent botId={id} />
</TabsContent>
{canViewMonitoring && (
<TabsContent
value="logs"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<BotLogListComponent botId={id} />
</TabsContent>
)}
{/* Tab: Sessions */}
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent>
{canViewMonitoring && (
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent>
)}
</Tabs>
</div>
@@ -120,6 +120,7 @@ export default function BotForm({
// Track whether initial data loading is complete.
// setValue calls during init should NOT mark the form as dirty.
const isInitializing = useRef(true);
const initializationSequence = useRef(0);
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
useState(new Map<string, IDynamicFormItemSchema[]>());
@@ -141,7 +142,7 @@ export default function BotForm({
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
IDynamicFormItemSchema[]
>([]);
const [, setIsLoading] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
@@ -162,50 +163,55 @@ export default function BotForm({
}, [isDirty, onDirtyChange]);
useEffect(() => {
setBotFormValues();
}, []);
void setBotFormValues();
return () => {
// React StrictMode may start the effect twice. Invalidate the older
// request so it cannot reset fields after the user starts editing.
initializationSequence.current += 1;
};
}, [initBotId]);
function setBotFormValues() {
async function setBotFormValues() {
const sequence = ++initializationSequence.current;
isInitializing.current = true;
initBotFormComponent().then(() => {
if (initBotId) {
getBotConfig(initBotId)
.then((val) => {
// Use form.reset() to set values AND update the dirty baseline,
// so isDirty stays false after initial load.
form.reset({
name: val.name,
description: val.description,
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
pipeline_routing_rules: val.pipeline_routing_rules || [],
});
handleAdapterSelect(val.adapter);
setIsLoading(true);
try {
await initBotFormComponent();
if (sequence !== initializationSequence.current) return;
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
setWebhookUrl('');
}
setExtraWebhookUrl(val.extra_webhook_full_url || '');
})
.catch((err) => {
toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg,
);
})
.finally(() => {
isInitializing.current = false;
});
if (initBotId) {
const val = await getBotConfig(initBotId);
if (sequence !== initializationSequence.current) return;
// Use form.reset() to set values AND update the dirty baseline,
// so isDirty stays false after initial load.
form.reset({
name: val.name,
description: val.description,
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
pipeline_routing_rules: val.pipeline_routing_rules || [],
});
handleAdapterSelect(val.adapter);
setWebhookUrl(val.webhook_full_url || '');
setExtraWebhookUrl(val.extra_webhook_full_url || '');
} else {
form.reset();
setWebhookUrl('');
setExtraWebhookUrl('');
isInitializing.current = false;
}
});
} catch (err) {
if (sequence === initializationSequence.current) {
toast.error(t('bots.getBotConfigError') + (err as CustomApiError).msg);
}
} finally {
if (sequence === initializationSequence.current) {
isInitializing.current = false;
setIsLoading(false);
}
}
}
async function initBotFormComponent() {
@@ -373,10 +379,6 @@ export default function BotForm({
.createBot(newBot)
.then((res) => {
toast.success(t('bots.createSuccess'));
initBotId = res.uuid;
setBotFormValues();
onNewBotCreated(res.uuid);
})
.catch((err) => {
@@ -395,243 +397,256 @@ export default function BotForm({
id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-6"
aria-busy={isLoading}
>
{/* Card 1: Basic Information */}
<Card>
<CardHeader>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
<CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.botName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.botDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 2: Pipeline Binding (edit mode only) */}
{initBotId && (
<fieldset className="contents" disabled={isLoading}>
{/* Card 1: Basic Information */}
<Card>
<CardHeader>
<CardTitle>{t('bots.routingConnection')}</CardTitle>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
<CardDescription>
{t('bots.routingConnectionDescription')}
{t('bots.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="use_pipeline_uuid"
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormLabel>
{t('bots.botName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger>
{field.value ? (
(() => {
const pipeline = pipelineNameList.find(
(p) => p.value === field.value,
);
return (
<div className="flex items-center gap-2">
{pipeline?.emoji && (
<span className="text-sm shrink-0">
{pipeline.emoji}
</span>
)}
<span>{pipeline?.label ?? field.value}</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
{item.emoji && (
<span className="text-sm shrink-0">
{item.emoji}
</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Pipeline Routing Rules */}
<RoutingRulesEditor
form={form}
pipelineNameList={pipelineNameList}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.botDescription')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
{/* Card 3: Adapter Configuration */}
<Card>
<CardHeader>
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
<CardDescription>
{t('bots.adapterConfigDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="adapter"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.platformAdapter')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-[240px]">
{field.value ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(field.value)}
alt=""
className="h-5 w-5 rounded"
{/* Card 2: Pipeline Binding (edit mode only) */}
{initBotId && (
<Card>
<CardHeader>
<CardTitle>{t('bots.routingConnection')}</CardTitle>
<CardDescription>
{t('bots.routingConnectionDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem>
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger>
{field.value ? (
(() => {
const pipeline = pipelineNameList.find(
(p) => p.value === field.value,
);
return (
<div className="flex items-center gap-2">
{pipeline?.emoji && (
<span className="text-sm shrink-0">
{pipeline.emoji}
</span>
)}
<span>
{pipeline?.label ?? field.value}
</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
<span>
{adapterNameList.find(
(a) => a.value === field.value,
)?.label ?? field.value}
</span>
</div>
) : (
<SelectValue
placeholder={t('bots.selectAdapter')}
/>
)}
</SelectTrigger>
<SelectContent>
{groupedAdapters.map((group) => (
<SelectGroup
key={group.categoryId ?? 'uncategorized'}
>
{group.categoryId && (
<SelectLabel>
{getCategoryLabel(t, group.categoryId)}
</SelectLabel>
)}
{group.items.map((item) => (
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 rounded"
/>
{item.emoji && (
<span className="text-sm shrink-0">
{item.emoji}
</span>
)}
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
{currentAdapter &&
(() => {
const docUrl = getAdapterDocUrl(
adapterHelpLinks[currentAdapter],
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
>
{t('bots.viewAdapterDocs')}
<ExternalLink className="h-3 w-3" />
</a>
) : null;
})()}
</div>
</FormControl>
{currentAdapter && adapterDescriptionList[currentAdapter] && (
<FormDescription>
{adapterDescriptionList[currentAdapter]}
</FormDescription>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
<FormMessage />
</FormItem>
)}
/>
/>
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values, {
shouldDirty: !isInitializing.current,
});
}}
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
outbound_ips: systemInfo.outbound_ips,
}}
{/* Pipeline Routing Rules */}
<RoutingRulesEditor
form={form}
pipelineNameList={pipelineNameList}
/>
</CardContent>
</Card>
)}
{/* Card 3: Adapter Configuration */}
<Card>
<CardHeader>
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
<CardDescription>
{t('bots.adapterConfigDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="adapter"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.platformAdapter')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-[240px]">
{field.value ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
field.value,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>
{adapterNameList.find(
(a) => a.value === field.value,
)?.label ?? field.value}
</span>
</div>
) : (
<SelectValue
placeholder={t('bots.selectAdapter')}
/>
)}
</SelectTrigger>
<SelectContent>
{groupedAdapters.map((group) => (
<SelectGroup
key={group.categoryId ?? 'uncategorized'}
>
{group.categoryId && (
<SelectLabel>
{getCategoryLabel(t, group.categoryId)}
</SelectLabel>
)}
{group.items.map((item) => (
<SelectItem
key={item.value}
value={item.value}
>
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
{currentAdapter &&
(() => {
const docUrl = getAdapterDocUrl(
adapterHelpLinks[currentAdapter],
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
>
{t('bots.viewAdapterDocs')}
<ExternalLink className="h-3 w-3" />
</a>
) : null;
})()}
</div>
</FormControl>
{currentAdapter &&
adapterDescriptionList[currentAdapter] && (
<FormDescription>
{adapterDescriptionList[currentAdapter]}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
)}
</CardContent>
</Card>
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values, {
shouldDirty: !isInitializing.current,
});
}}
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
outbound_ips: systemInfo.outbound_ips,
}}
/>
)}
</CardContent>
</Card>
</fieldset>
</form>
</Form>
);
@@ -58,19 +58,9 @@ export default function AccountSettingsPanel({
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
setSpaceBindLoading(false);
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
// Pass token as state for security verification
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
@@ -40,12 +40,20 @@ import { PanelToolbar } from '../settings-dialog/panel-layout';
interface ApiKey {
id: number;
uuid: string;
name: string;
key: string;
description: string;
scopes: string[];
status: 'active' | 'revoked';
secret_available: false;
created_at: string;
}
type CreatedApiKey = Omit<ApiKey, 'secret_available'> & {
key: string;
secret_available: true;
};
interface Webhook {
id: number;
name: string;
@@ -71,7 +79,7 @@ export default function ApiIntegrationPanel({
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyDescription, setNewKeyDescription] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
// Webhook state
@@ -135,7 +143,7 @@ export default function ApiIntegrationPanel({
const response = (await backendClient.post('/api/v1/apikeys', {
name: newKeyName,
description: newKeyDescription,
})) as { key: ApiKey };
})) as { key: CreatedApiKey };
setCreatedKey(response.key);
toast.success(t('common.apiKeyCreated'));
@@ -184,11 +192,6 @@ export default function ApiIntegrationPanel({
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
};
const maskApiKey = (key: string) => {
if (key.length <= 8) return key;
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;
};
// Webhook methods
const loadWebhooks = async () => {
setLoading(true);
@@ -337,25 +340,12 @@ export default function ApiIntegrationPanel({
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
<span className="text-sm text-muted-foreground">
{t('common.apiKeyStoredSecurely')}
</span>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
@@ -188,15 +188,10 @@ export default function DynamicFormItemComponent({
const handleSpaceLogin = () => {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
httpClient
.getSpaceAuthorizeUrl(redirectUri, token)
.getSpaceBindAuthorizeUrl(redirectUri)
.then((response) => {
window.location.href = response.authorize_url;
})
@@ -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,
@@ -32,6 +36,7 @@ import {
Server,
Puzzle,
RefreshCcw,
UsersRound,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
@@ -57,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,
@@ -380,6 +388,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 +526,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 +568,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 +817,7 @@ function NavItems({
{itemIsPluginType && !item.debug && (
<PluginItemMenu
item={item}
canManage={canManageResources}
onUpdate={() => handlePluginUpdate(item)}
onDelete={() => handlePluginDelete(item)}
/>
@@ -1063,7 +1081,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 +1348,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 +1364,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 +1406,7 @@ function PluginItemMenu({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
{isMarketplace && (
{canManage && isMarketplace && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -1413,16 +1435,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 +1636,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');
@@ -1655,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());
@@ -1678,10 +1716,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 +1854,7 @@ export default function HomeSidebar({
}
function handleLogout() {
clearUserInfo();
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
window.location.href = '/login';
@@ -1880,6 +1915,10 @@ export default function HomeSidebar({
</SidebarMenu>
</SidebarHeader>
<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>
{/* Navigation items grouped by section */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
@@ -1948,18 +1987,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>
@@ -2048,6 +2089,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);
@@ -24,6 +24,8 @@ import {
} from './types';
import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
import { useCurrentWorkspace } from '@/app/infra/http';
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open.
@@ -83,10 +85,13 @@ export default function ModelsPanel({
onBlockingChange,
}: ModelsPanelProps) {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
const [spaceBilling, setSpaceBilling] =
useState<WorkspaceSpaceBilling | null>(null);
// Expanded providers and their models
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
@@ -140,7 +145,7 @@ export default function ModelsPanel({
useEffect(() => {
if (active) {
loadUserInfo();
loadWorkspaceBilling();
loadProviders();
loadRequesterSupportTypes();
}
@@ -163,16 +168,11 @@ export default function ModelsPanel({
}
}, [providersLoaded, providers]);
async function loadUserInfo() {
async function loadWorkspaceBilling() {
try {
const userInfo = await httpClient.getUserInfo();
setAccountType(userInfo.account_type);
if (userInfo.account_type === 'space') {
const creditsInfo = await httpClient.getSpaceCredits();
setSpaceCredits(creditsInfo.credits);
}
setSpaceBilling(await httpClient.getWorkspaceSpaceBilling());
} catch {
setAccountType('local');
setSpaceBilling(null);
}
}
@@ -270,17 +270,9 @@ export default function ModelsPanel({
async function handleSpaceLogin() {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
@@ -544,13 +536,15 @@ export default function ModelsPanel({
<ProviderCard
key={provider.uuid}
provider={provider}
canManage={canManage}
isLangBotModels={isLangBotModels}
supportTypes={requesterSupportTypes[provider.requester]}
isExpanded={expandedProviders.has(provider.uuid)}
isLoading={loadingProviders.has(provider.uuid)}
models={providerModels[provider.uuid]}
accountType={accountType}
spaceCredits={spaceCredits}
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
spaceCredits={spaceBilling?.credits ?? null}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
@@ -628,10 +622,12 @@ export default function ModelsPanel({
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
{canManage && (
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
)}
</div>
{/* Provider List */}
@@ -18,6 +18,7 @@ import { userInfo } from '@/app/infra/http';
interface ModelItemProps {
model: LLMModel | EmbeddingModel;
canManage: boolean;
modelType: ModelType;
isLangBotModels: boolean;
editModelPopoverOpen: string | null;
@@ -71,6 +72,7 @@ function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
export default function ModelItem({
model,
canManage,
modelType,
isLangBotModels,
editModelPopoverOpen,
@@ -149,7 +151,7 @@ export default function ModelItem({
// Check if popover should be disabled (space models when not logged in)
const isPopoverDisabled =
isLangBotModels && userInfo?.account_type !== 'space';
!canManage || (isLangBotModels && userInfo?.account_type !== 'space');
return (
<Popover
@@ -193,7 +195,7 @@ export default function ModelItem({
</Badge>
)}
</div>
{!isLangBotModels && (
{canManage && !isLangBotModels && (
<Popover
open={isDeleteOpen}
onOpenChange={(open) =>
@@ -38,12 +38,14 @@ import AddModelPopover from './AddModelPopover';
interface ProviderCardProps {
provider: ModelProvider;
canManage: boolean;
isLangBotModels?: boolean;
supportTypes?: string[];
isExpanded: boolean;
isLoading: boolean;
models?: ProviderModels;
accountType: 'local' | 'space';
isWorkspaceOwner: boolean;
ownerSpaceBound: boolean;
spaceCredits: number | null;
// Popover states
addModelPopoverOpen: string | null;
@@ -101,12 +103,14 @@ function maskApiKey(key: string): string {
export default function ProviderCard({
provider,
canManage,
isLangBotModels = false,
supportTypes,
isExpanded,
isLoading,
models,
accountType,
isWorkspaceOwner,
ownerSpaceBound,
spaceCredits,
addModelPopoverOpen,
editModelPopoverOpen,
@@ -196,7 +200,7 @@ export default function ProviderCard({
</div>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
{isLangBotModels && accountType !== 'space' && (
{isLangBotModels && isWorkspaceOwner && !ownerSpaceBound && (
<Button
variant="outline"
size="sm"
@@ -206,33 +210,41 @@ export default function ProviderCard({
}}
>
<LogIn className="h-4 w-4 mr-1" />
{t('models.loginWithSpace')}
{t('models.ownerMustBindSpace')}
</Button>
)}
{isLangBotModels &&
accountType === 'space' &&
spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{!isLangBotModels && (
{isLangBotModels && ownerSpaceBound && spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
<span className="text-xs text-muted-foreground">
{t('models.usesOwnerSpaceBilling')}
</span>
)}
{isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && (
<span className="text-xs text-muted-foreground">
{t('models.ownerMustBindSpace')}
</span>
)}
{canManage && !isLangBotModels && (
<>
<Button
variant="ghost"
@@ -317,7 +329,7 @@ export default function ProviderCard({
) : (
<div />
)}
{!isLangBotModels && (
{canManage && !isLangBotModels && (
<div className="flex items-center gap-1">
<AddModelPopover
isOpen={
@@ -404,6 +416,7 @@ export default function ProviderCard({
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="llm"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
@@ -441,6 +454,7 @@ export default function ProviderCard({
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="embedding"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
@@ -472,6 +486,7 @@ export default function ProviderCard({
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="rerank"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
@@ -1,6 +1,12 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
import {
HardDrive,
KeyRound,
Settings,
Sparkles,
UsersRound,
} from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -22,10 +28,13 @@ import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/
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'
@@ -35,6 +44,7 @@ export type SettingsSection =
// (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',
@@ -63,6 +73,7 @@ export default function SettingsDialog({
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);
@@ -76,13 +87,20 @@ export default function SettingsDialog({
}
}, [section, open]);
const navItems: {
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'),
@@ -112,6 +130,27 @@ export default function SettingsDialog({
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');
@@ -201,6 +240,11 @@ export default function SettingsDialog({
</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'}
@@ -0,0 +1,413 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Copy,
ExternalLink,
Loader2,
Trash2,
UserPlus,
Users,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@/components/ui/item';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type {
CurrentWorkspace,
WorkspaceInvitation,
WorkspaceMembership,
WorkspaceRole,
} from '@/app/infra/entities/workspace';
import { backendClient, systemInfo } from '@/app/infra/http';
import {
PanelBody,
PanelToolbar,
} from '@/app/home/components/settings-dialog/panel-layout';
interface WorkspaceSettingsPanelProps {
active: boolean;
}
const ASSIGNABLE_ROLES: Exclude<WorkspaceRole, 'owner'>[] = [
'admin',
'developer',
'operator',
'viewer',
];
export default function WorkspaceSettingsPanel({
active,
}: WorkspaceSettingsPanelProps) {
const { t } = useTranslation();
const [workspaceInfo, setWorkspaceInfo] = useState<CurrentWorkspace | null>(
null,
);
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
const [loading, setLoading] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] =
useState<Exclude<WorkspaceRole, 'owner'>>('viewer');
const [inviteLoading, setInviteLoading] = useState(false);
const [oneTimeInviteLink, setOneTimeInviteLink] = useState<string | null>(
null,
);
const permissions = useMemo(
() => new Set(workspaceInfo?.permissions ?? []),
[workspaceInfo],
);
const isCloudProjection =
workspaceInfo?.workspace.source === 'cloud_projection';
const canViewMembers = permissions.has('member.view');
const canInvite = permissions.has('member.invite');
const canUpdateMembers = permissions.has('member.update_role');
const canRemoveMembers = permissions.has('member.remove');
const canTransferOwner = permissions.has('owner.transfer');
const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: '';
const loadWorkspace = useCallback(async () => {
setLoading(true);
try {
const current = await backendClient.getCurrentWorkspace();
setWorkspaceInfo(current);
const [memberResponse, invitationResponse] = await Promise.all([
current.permissions.includes('member.view')
? backendClient.getWorkspaceMembers(current.workspace.uuid)
: Promise.resolve({ members: [] }),
current.permissions.includes('member.invite')
? backendClient.getWorkspaceInvitations(current.workspace.uuid)
: Promise.resolve({ invitations: [] }),
]);
setMembers(memberResponse.members);
setInvitations(invitationResponse.invitations);
} catch {
toast.error(t('workspace.loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
if (active) void loadWorkspace();
}, [active, loadWorkspace]);
async function createInvitation() {
if (!workspaceInfo || !inviteEmail.trim()) return;
setInviteLoading(true);
try {
const response = await backendClient.createWorkspaceInvitation(
workspaceInfo.workspace.uuid,
inviteEmail.trim(),
inviteRole,
);
setOneTimeInviteLink(response.link);
setInviteEmail('');
await loadWorkspace();
toast.success(t(`workspace.delivery.${response.delivery.status}`));
} catch {
toast.error(t('workspace.invitationCreateFailed'));
} finally {
setInviteLoading(false);
}
}
async function copyInvitationLink() {
if (!oneTimeInviteLink) return;
await navigator.clipboard.writeText(oneTimeInviteLink);
toast.success(t('workspace.invitationCopied'));
}
async function updateMemberRole(
member: WorkspaceMembership,
role: WorkspaceRole,
) {
if (!workspaceInfo || member.role === role) return;
try {
await backendClient.updateWorkspaceMemberRole(
workspaceInfo.workspace.uuid,
member.account_uuid,
role,
);
await loadWorkspace();
toast.success(t('workspace.memberUpdated'));
} catch {
toast.error(t('workspace.memberUpdateFailed'));
}
}
async function removeMember(member: WorkspaceMembership) {
if (!workspaceInfo) return;
if (!window.confirm(t('workspace.removeMemberConfirm'))) return;
try {
await backendClient.removeWorkspaceMember(
workspaceInfo.workspace.uuid,
member.account_uuid,
);
await loadWorkspace();
toast.success(t('workspace.memberRemoved'));
} catch {
toast.error(t('workspace.memberRemoveFailed'));
}
}
async function revokeInvitation(invitation: WorkspaceInvitation) {
if (!workspaceInfo) return;
try {
await backendClient.revokeWorkspaceInvitation(
workspaceInfo.workspace.uuid,
invitation.uuid,
);
await loadWorkspace();
toast.success(t('workspace.invitationRevoked'));
} catch {
toast.error(t('workspace.invitationRevokeFailed'));
}
}
if (loading && !workspaceInfo) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<>
<PanelToolbar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{workspaceInfo?.workspace.name ?? t('workspace.title')}
</p>
<p className="text-xs text-muted-foreground">
{t(
isCloudProjection
? 'workspace.cloudManagedDescription'
: 'workspace.ossSingletonDescription',
)}
</p>
</div>
{workspaceInfo && (
<Badge variant="secondary">
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
</Badge>
)}
{isCloudProjection && workspaceInfo && (
<Button asChild size="sm">
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
{t('workspace.upgradePlan')}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
</PanelToolbar>
<PanelBody className="space-y-6">
{canInvite && (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">
{t('workspace.inviteMember')}
</h3>
<p className="text-xs text-muted-foreground">
{t('workspace.inviteDescription')}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
type="email"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
placeholder={t('workspace.emailPlaceholder')}
className="flex-1"
/>
<Select
value={inviteRole}
onValueChange={(value) =>
setInviteRole(value as Exclude<WorkspaceRole, 'owner'>)
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={createInvitation}
disabled={inviteLoading || !inviteEmail.trim()}
>
{inviteLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserPlus className="size-4" />
)}
{t('workspace.createInvitation')}
</Button>
</div>
{oneTimeInviteLink && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
<p className="mb-2 text-xs text-muted-foreground">
{t('workspace.oneTimeLinkWarning')}
</p>
<div className="flex gap-2">
<Input value={oneTimeInviteLink} readOnly />
<Button
variant="outline"
size="icon"
onClick={copyInvitationLink}
aria-label={t('workspace.copyInvitation')}
>
<Copy className="size-4" />
</Button>
</div>
</div>
)}
</section>
)}
{canViewMembers && (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">
{t('workspace.members')}
</h3>
</div>
<div className="space-y-2">
{members.map((member) => {
const isSelf =
member.account_uuid ===
workspaceInfo?.membership.account_uuid;
return (
<Item
key={member.uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
<Users className="size-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>
{member.email}
{isSelf && (
<Badge variant="outline">{t('workspace.you')}</Badge>
)}
</ItemTitle>
<ItemDescription>
{t(`workspace.roles.${member.role}`)}
</ItemDescription>
</ItemContent>
<ItemActions>
{canUpdateMembers && member.role !== 'owner' && (
<Select
value={member.role}
onValueChange={(role) =>
void updateMemberRole(member, role as WorkspaceRole)
}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
{canTransferOwner && (
<SelectItem value="owner">
{t('workspace.transferOwnership')}
</SelectItem>
)}
</SelectContent>
</Select>
)}
{canRemoveMembers &&
!isSelf &&
member.role !== 'owner' && (
<Button
variant="ghost"
size="icon"
onClick={() => void removeMember(member)}
aria-label={t('workspace.removeMember')}
>
<Trash2 className="size-4" />
</Button>
)}
</ItemActions>
</Item>
);
})}
</div>
</section>
)}
{canInvite && invitations.length > 0 && (
<section className="space-y-3">
<h3 className="text-sm font-semibold">
{t('workspace.pendingInvitations')}
</h3>
<div className="space-y-2">
{invitations.map((invitation) => (
<Item
key={invitation.uuid}
size="sm"
variant="outline"
className="rounded-lg"
>
<ItemContent>
<ItemTitle>{invitation.normalized_email}</ItemTitle>
<ItemDescription>
{t(`workspace.roles.${invitation.role}`)} ·{' '}
{t('workspace.expiresAt', {
date: new Date(invitation.expires_at).toLocaleString(),
})}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="ghost"
size="icon"
onClick={() => void revokeInvitation(invitation)}
aria-label={t('workspace.revokeInvitation')}
>
<Trash2 className="size-4" />
</Button>
</ItemActions>
</Item>
))}
</div>
</section>
)}
</PanelBody>
</>
);
}
@@ -0,0 +1,96 @@
import { Building2, Check, Settings } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
switchWorkspaceAndReload,
useCurrentWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
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,
}: {
className?: string;
}) {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap();
if (!currentWorkspace) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('h-9 min-w-0 justify-start px-2.5 text-sm', className)}
aria-label={t('workspace.switchWorkspace')}
>
<Building2 className="size-4 shrink-0" />
<span className="truncate">{currentWorkspace.workspace.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 p-1.5">
<DropdownMenuLabel className="px-3 py-2 text-sm">
{t('workspace.switchWorkspace')}
</DropdownMenuLabel>
{workspaces.map((entry) => {
const selected =
entry.workspace.uuid === currentWorkspace.workspace.uuid;
return (
<DropdownMenuItem
key={entry.workspace.uuid}
className="min-h-11 gap-2 px-2 py-1.5"
onClick={() => {
if (!selected)
void switchWorkspaceAndReload(entry.workspace.uuid);
}}
>
<Building2 className="size-4 shrink-0" />
<span className="max-w-[7rem] min-w-0 flex-1 truncate font-medium">
{entry.workspace.name}
</span>
{entry.workspace.source === 'cloud_projection' && (
<span className="rounded-md border bg-muted px-2 py-0.5 text-[11px] font-medium uppercase text-muted-foreground">
{entry.plan_name || t('workspace.planUnavailable')}
</span>
)}
{selected && <Check className="size-4" />}
{selected && (
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={t('workspace.settings')}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
requestWorkspaceSettings();
}}
>
<Settings className="size-4" />
</Button>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+73 -57
View File
@@ -27,11 +27,15 @@ import { KnowledgeBase } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner';
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
useSidebarData();
@@ -119,18 +123,22 @@ export default function KBDetailContent({ id }: { id: string }) {
<h1 className="text-xl font-semibold">
{t('knowledge.createKnowledgeBase')}
</h1>
<Button type="submit" form="kb-form">
{t('common.submit')}
</Button>
{canManage && (
<Button type="submit" form="kb-form">
{t('common.submit')}
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<KBForm
initKbId={undefined}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
/>
<fieldset className="contents" disabled={!canManage}>
<KBForm
initKbId={undefined}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
/>
</fieldset>
</div>
</div>
</div>
@@ -146,14 +154,16 @@ export default function KBDetailContent({ id }: { id: string }) {
<h1 className="text-xl font-semibold">
{t('knowledge.editKnowledgeBase')}
</h1>
<Button
type="submit"
form="kb-form"
disabled={!formDirty}
className={activeTab !== 'metadata' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
{canManage && (
<Button
type="submit"
form="kb-form"
disabled={!formDirty}
className={activeTab !== 'metadata' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
)}
</div>
{/* Horizontal Tabs */}
@@ -186,45 +196,49 @@ export default function KBDetailContent({ id }: { id: string }) {
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<KBForm
initKbId={id}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
onDirtyChange={setFormDirty}
/>
<fieldset className="contents" disabled={!canManage}>
<KBForm
initKbId={id}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
onDirtyChange={setFormDirty}
/>
</fieldset>
{/* Danger Zone Card */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('knowledge.dangerZone')}
</CardTitle>
<CardDescription>
{t('knowledge.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('knowledge.deleteKbAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('knowledge.deleteKbHint')}
</p>
{canManage && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('knowledge.dangerZone')}
</CardTitle>
<CardDescription>
{t('knowledge.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('knowledge.deleteKbAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('knowledge.deleteKbHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
)}
</div>
</TabsContent>
@@ -234,11 +248,13 @@ export default function KBDetailContent({ id }: { id: string }) {
value="documents"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<KBDoc
kbId={id}
ragEngineName={kbInfo?.knowledge_engine?.name}
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
/>
<fieldset className="contents" disabled={!canManage}>
<KBDoc
kbId={id}
ragEngineName={kbInfo?.knowledge_engine?.name}
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
/>
</fieldset>
</TabsContent>
)}
+66 -35
View File
@@ -14,10 +14,10 @@ import {
} from '@/app/home/components/home-sidebar/SidebarDataContext';
import { I18nObject } from '@/app/infra/entities/common';
import {
userInfo,
bootstrapWorkspaceSession,
systemInfo,
initializeUserInfo,
initializeSystemInfo,
useCurrentWorkspace,
} from '@/app/infra/http';
import { useNavigate, useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
@@ -82,8 +82,6 @@ function isExtensionsRoute(pathname: string): boolean {
}
const HOME_CONTENT_MAX_WIDTH = 'max-w-[1360px]';
const BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY =
'langbot_backend_unavailable_return_to';
export default function HomeLayout({
children,
@@ -92,45 +90,78 @@ export default function HomeLayout({
}>) {
const navigate = useNavigate();
const location = useLocation();
const currentWorkspace = useCurrentWorkspace();
const [identityReady, setIdentityReady] = useState(false);
// Initialize user info if not already initialized
useEffect(() => {
if (!userInfo) {
initializeUserInfo();
}
}, []);
// Auto-redirect to wizard on first visit (wizard not yet completed on this instance)
// Resolve the instance, Account, and Workspace before mounting any
// Workspace-owned page. The second system-info read uses the now-stable
// selector and therefore returns the selected Workspace's wizard metadata.
useEffect(() => {
let cancelled = false;
const checkWizard = async () => {
try {
// Always re-fetch to ensure we have the latest wizard_status from backend
await initializeSystemInfo({ throwOnError: true });
if (!cancelled && systemInfo.wizard_status === 'none') {
navigate('/wizard', { replace: true });
}
} catch {
if (!cancelled) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
sessionStorage.setItem(
BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY,
returnTo,
bootstrapWorkspaceSession()
.then(async (result) => {
if (result.status === 'selection-required') {
const returnTo = `${location.pathname}${location.search}`;
navigate(
`/workspaces/select?returnTo=${encodeURIComponent(returnTo)}`,
{ replace: true },
);
navigate('/backend-unavailable', {
replace: true,
state: { from: returnTo },
});
return false;
}
}
};
checkWizard();
if (result.status === 'unavailable') {
throw new Error('No Workspace is available for this Account');
}
await initializeSystemInfo({ throwOnError: true });
return true;
})
.then((ready) => {
if (!cancelled && ready) setIdentityReady(true);
})
.catch(() => {
if (!cancelled) navigate('/login', { replace: true });
});
return () => {
cancelled = true;
};
}, [location.hash, location.pathname, location.search, navigate]);
}, [location.pathname, location.search, navigate]);
// A read-only member may view resources but must not enter mutation-only
// routes. The backend remains the authoritative authorization boundary.
useEffect(() => {
if (!identityReady || !currentWorkspace) return;
if (currentWorkspace.permissions.includes('resource.manage')) return;
const params = new URLSearchParams(location.search);
const createOnlyRoute =
location.pathname === '/home/add-extension' ||
params.get('id') === 'new' ||
(location.pathname === '/home/skills' &&
params.get('action') === 'create');
if (createOnlyRoute) {
const fallback =
location.pathname === '/home/add-extension'
? '/home/extensions'
: location.pathname;
navigate(fallback, { replace: true });
}
}, [
currentWorkspace,
identityReady,
location.pathname,
location.search,
navigate,
]);
// Auto-redirect only after the Workspace bootstrap above has loaded the
// selected Workspace's wizard state.
useEffect(() => {
if (!identityReady) return;
if (systemInfo.wizard_status === 'none') {
navigate('/wizard', { replace: true });
}
}, [identityReady, navigate]);
if (!identityReady) return <div />;
return (
<SidebarDataProvider>
+74 -55
View File
@@ -26,6 +26,7 @@ import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataCo
import { useTranslation } from 'react-i18next';
import { Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { useCurrentWorkspace } from '@/app/infra/http';
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
type MCPConnectionState =
@@ -39,6 +40,11 @@ export default function MCPDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canOperate =
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
const { refreshMCPServers, mcpServers, setDetailEntityName } =
useSidebarData();
const server = mcpServers.find((s) => s.id === id);
@@ -212,21 +218,25 @@ export default function MCPDetailContent({ id }: { id: string }) {
</Badge>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => navigate('/home/add-extension')}
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
{canOperate && (
<Button
type="button"
variant="outline"
onClick={() => navigate('/home/add-extension')}
>
{t('common.cancel')}
</Button>
)}
{canManage && (
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
)}
<Button
type="submit"
form="mcp-form"
@@ -243,15 +253,17 @@ export default function MCPDetailContent({ id }: { id: string }) {
</div>
<div className="min-h-0 flex-1">
<MCPForm
ref={formRef}
initServerName={undefined}
layout="split"
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
/>
<fieldset className="contents" disabled={!canManage}>
<MCPForm
ref={formRef}
initServerName={undefined}
layout="split"
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
/>
</fieldset>
</div>
</div>
);
@@ -274,6 +286,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
id="mcp-enable-switch"
checked={serverEnabled}
onCheckedChange={handleEnableToggle}
disabled={!canManage}
/>
</div>
</CardContent>
@@ -335,41 +348,47 @@ export default function MCPDetailContent({ id }: { id: string }) {
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
<Button
type="submit"
form="mcp-form"
disabled={!formDirty || saveBlockedByBox}
>
{t('common.save')}
</Button>
{canOperate && (
<Button
type="button"
variant="outline"
onClick={() => formRef.current?.testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
)}
{canManage && (
<Button
type="submit"
form="mcp-form"
disabled={!formDirty || saveBlockedByBox}
>
{t('common.save')}
</Button>
)}
</div>
</div>
<div className="min-h-0 flex-1">
<MCPForm
ref={formRef}
initServerName={id}
layout="split"
sideHeader={enableControl}
sideFooter={editActions}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
onRuntimeInfoChange={(runtimeInfo) =>
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
}
onPersistedTestComplete={handlePersistedTestComplete}
/>
<fieldset className="contents" disabled={!canManage}>
<MCPForm
ref={formRef}
initServerName={id}
layout="split"
sideHeader={enableControl}
sideFooter={canManage ? editActions : undefined}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
onRuntimeInfoChange={(runtimeInfo) =>
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
}
onPersistedTestComplete={handlePersistedTestComplete}
/>
</fieldset>
</div>
</div>
@@ -56,6 +56,7 @@ import {
import { CustomApiError } from '@/app/infra/entities/common';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { useMCPStdioPolicy } from '@/app/infra/hooks/useMCPStdioPolicy';
function StatusDisplay({
testing,
@@ -566,11 +567,15 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
const { enabled: mcpStdioEnabled } = useMCPStdioPolicy();
// stdio mode requires the Box sandbox at runtime. If the user picks
// stdio while Box is disabled / unreachable, the server would refuse
// to start anyway — block creation upfront so they aren't surprised
// by an immediate "Connection failed" on the detail page.
const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
const stdioBlockedByPolicy = watchMode === 'stdio' && !mcpStdioEnabled;
const stdioBlockedByBox =
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
const { isDirty } = form.formState;
useEffect(() => {
@@ -578,8 +583,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
}, [isDirty, onDirtyChange]);
useEffect(() => {
onSaveBlockedChange?.(stdioBlockedByBox);
}, [stdioBlockedByBox, onSaveBlockedChange]);
onSaveBlockedChange?.(stdioBlocked);
}, [stdioBlocked, onSaveBlockedChange]);
useEffect(() => {
onTestingChange?.(mcpTesting);
@@ -595,10 +600,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
testMcp: () => testMcp(),
isTesting: mcpTesting,
}),
// testMcp now reads everything via form.getValues(), so it does not need
// the latest stdioArgs/extraArgs closure — but keep mcpTesting so the
// exposed isTesting flag stays accurate.
[mcpTesting],
// Form values are read through form.getValues(); policy and runtime health
// remain closure values and must refresh the imperative handler.
[mcpTesting, mcpStdioEnabled, boxAvailable],
);
useEffect(() => {
@@ -760,6 +764,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
// Belt-and-suspenders: even though the Save button is disabled when
// stdio is unselectable, intercept programmatic submits too.
if (value.mode === 'stdio' && !mcpStdioEnabled) {
toast.error(t('mcp.stdioDisabledByPolicy'));
return;
}
if (value.mode === 'stdio' && !boxAvailable) {
toast.error(t('mcp.stdioBlockedByBoxToast'));
return;
@@ -829,6 +837,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
try {
const mode = form.getValues('mode');
if (mode === 'stdio' && !mcpStdioEnabled) {
toast.error(t('mcp.stdioDisabledByPolicy'));
setMcpTesting(false);
return;
}
if (mode === 'stdio' && !boxAvailable) {
toast.error(t('mcp.stdioBlockedByBoxToast'));
setMcpTesting(false);
return;
}
// Read every field via form.getValues() rather than the captured
// `stdioArgs` / `extraArgs` state. testMcp() is invoked through an
// imperative handle (formRef.current.testMcp()) whose closure is only
@@ -1037,13 +1055,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
</FormControl>
<SelectContent>
<SelectItem value="remote">{t('mcp.remote')}</SelectItem>
<SelectItem value="stdio" disabled={!boxAvailable}>
<SelectItem
value="stdio"
disabled={!mcpStdioEnabled || !boxAvailable}
>
{t('mcp.local')}
{!boxAvailable && (
{!mcpStdioEnabled ? (
<span className="ml-2 text-xs text-muted-foreground">
({t('mcp.disabledByPolicy')})
</span>
) : !boxAvailable ? (
<span className="ml-2 text-xs text-muted-foreground">
({t('mcp.boxRequired')})
</span>
)}
) : null}
</SelectItem>
</SelectContent>
</Select>
@@ -1052,6 +1077,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
? t('mcp.localModeDescription')
: t('mcp.remoteModeDescription')}
</FormDescription>
{stdioBlockedByPolicy && (
<div
role="alert"
className="mt-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200"
>
{t('mcp.stdioDisabledByPolicy')}
</div>
)}
{stdioBlockedByBox && (
<BoxUnavailableNotice
hint={boxHint}
@@ -67,7 +67,7 @@ export default function SystemStatusCard({
try {
const [plugin, box] = await Promise.all([
httpClient.getPluginSystemStatus().catch(() => null),
httpClient.getBoxStatus().catch(() => null),
httpClient.getBoxRuntimeStatus().catch(() => null),
]);
const sessions = box?.hidden
? []
+5 -1
View File
@@ -23,9 +23,13 @@ import { FeedbackStatsCards } from './components/FeedbackCard';
import { FeedbackList } from './components/FeedbackList';
import { buildConversationTurns } from './utils/conversationTurns';
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
import { useCurrentWorkspace } from '@/app/infra/http';
function MonitoringPageContent() {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canExport =
currentWorkspace?.permissions.includes('data.export') ?? false;
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
useMonitoringFilters();
const { data, loading, refetch } = useMonitoringData(filterState);
@@ -154,7 +158,7 @@ function MonitoringPageContent() {
onTimeRangeChange={setTimeRange}
/>
<div className="flex items-center gap-2">
<ExportDropdown filterState={filterState} />
{canExport && <ExportDropdown filterState={filterState} />}
<Button
variant="outline"
size="sm"
@@ -8,11 +8,19 @@ import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-ta
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, Bug, BarChart3 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function PipelineDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canOperate =
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
const canViewMonitoring =
currentWorkspace?.permissions.includes('resource.view') ?? false;
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
// Set breadcrumb entity name
@@ -47,22 +55,26 @@ export default function PipelineDetailContent({ id }: { id: string }) {
<h1 className="text-xl font-semibold">
{t('pipelines.createPipeline')}
</h1>
<Button type="submit" form="pipeline-form">
{t('common.submit')}
</Button>
{canManage && (
<Button type="submit" form="pipeline-form">
{t('common.submit')}
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-2xl space-y-6">
<PipelineFormComponent
pipelineId={undefined}
isEditMode={false}
disableForm={false}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
/>
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
pipelineId={undefined}
isEditMode={false}
disableForm={!canManage}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
/>
</fieldset>
</div>
</div>
</div>
@@ -80,14 +92,16 @@ export default function PipelineDetailContent({ id }: { id: string }) {
{/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
{canManage && (
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
)}
</div>
{/* Horizontal Tabs */}
@@ -102,21 +116,25 @@ export default function PipelineDetailContent({ id }: { id: string }) {
<Settings className="size-3.5" />
{t('pipelines.configuration')}
</TabsTrigger>
<TabsTrigger value="debug" className="gap-1.5">
<Bug className="size-3.5" />
{t('pipelines.debugChat')}
{activeTab === 'debug' && (
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
)}
</TabsTrigger>
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
{canOperate && (
<TabsTrigger value="debug" className="gap-1.5">
<Bug className="size-3.5" />
{t('pipelines.debugChat')}
{activeTab === 'debug' && (
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
)}
</TabsTrigger>
)}
{canViewMonitoring && (
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
)}
</TabsList>
{/* Tab: Configuration */}
@@ -124,41 +142,47 @@ export default function PipelineDetailContent({ id }: { id: string }) {
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineFormComponent
pipelineId={id}
isEditMode={true}
disableForm={false}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={handleDeletePipeline}
onCancel={() => navigate('/home/pipelines')}
onDirtyChange={setFormDirty}
/>
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
pipelineId={id}
isEditMode={true}
disableForm={!canManage}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={handleDeletePipeline}
onCancel={() => navigate('/home/pipelines')}
onDirtyChange={setFormDirty}
/>
</fieldset>
</TabsContent>
{/* Tab: Debug */}
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
<DebugDialog
open={activeTab === 'debug'}
pipelineId={id}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
{canOperate && (
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
<DebugDialog
open={activeTab === 'debug'}
pipelineId={id}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
)}
{/* Tab: Monitoring */}
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
navigate('/home/monitoring');
}}
/>
</TabsContent>
{canViewMonitoring && (
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
navigate('/home/monitoring');
}}
/>
</TabsContent>
)}
</Tabs>
</div>
);
+49 -33
View File
@@ -24,11 +24,15 @@ import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { Sparkles, Trash2 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function SkillDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id);
@@ -88,14 +92,16 @@ export default function SkillDetailContent({ id }: { id: string }) {
</Badge>
</div>
</div>
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
{canManage && (
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
)}
</div>
{!boxAvailable && (
@@ -105,13 +111,17 @@ export default function SkillDetailContent({ id }: { id: string }) {
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={() => {}}
/>
<fieldset className="contents" disabled={!canManage}>
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={(skillName) =>
handleImportedSkills([skillName])
}
onSkillUpdated={() => {}}
/>
</fieldset>
</div>
</div>
);
@@ -133,16 +143,18 @@ export default function SkillDetailContent({ id }: { id: string }) {
{t('skills.deleteConfirmation')}
</p>
</div>
<Button
variant="destructive"
type="button"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
{canManage && (
<Button
variant="destructive"
type="button"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
)}
</div>
</CardContent>
</Card>
@@ -185,14 +197,18 @@ export default function SkillDetailContent({ id }: { id: string }) {
)}
<div className="min-h-0 flex-1">
<SkillForm
key={id}
initSkillName={id}
layout="split"
sideFooter={editActions}
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={handleSkillUpdated}
/>
<fieldset className="contents" disabled={!canManage}>
<SkillForm
key={id}
initSkillName={id}
layout="split"
sideFooter={canManage ? editActions : undefined}
onNewSkillCreated={(skillName) =>
handleImportedSkills([skillName])
}
onSkillUpdated={handleSkillUpdated}
/>
</fieldset>
</div>
</div>
+18 -8
View File
@@ -7,9 +7,13 @@ import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function SkillsPage() {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
@@ -61,7 +65,11 @@ export default function SkillsPage() {
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" form="skill-form" disabled={!boxAvailable}>
<Button
type="submit"
form="skill-form"
disabled={!boxAvailable || !canManage}
>
{t('common.save')}
</Button>
</div>
@@ -72,13 +80,15 @@ export default function SkillsPage() {
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={handleCreatedSkill}
onSkillUpdated={() => {}}
/>
<fieldset className="contents" disabled={!canManage}>
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={handleCreatedSkill}
onSkillUpdated={() => {}}
/>
</fieldset>
</div>
</div>
);