chore(merge): sync master into dev/4.11.x

This commit is contained in:
huanghuoguoguo
2026-07-31 19:29:38 +08:00
502 changed files with 77975 additions and 12729 deletions
@@ -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"
@@ -15,6 +15,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
@@ -640,12 +641,9 @@ export default function DynamicFormComponent({
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
const initialFinalValues = normalizeDynamicFormValuesForSave(
editableValueSpecs,
formValues as Record<string, unknown>,
);
onSubmitRef.current?.(initialFinalValues);
@@ -660,12 +658,9 @@ export default function DynamicFormComponent({
const subscription = form.watch(() => {
const formValues = form.getValues();
const finalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
const finalValues = normalizeDynamicFormValuesForSave(
editableValueSpecs,
formValues as Record<string, unknown>,
);
onSubmitRef.current?.(finalValues);
previousInitialValues.current = finalValues as Record<string, object>;
@@ -228,15 +228,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;
})
@@ -0,0 +1,25 @@
import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
export type DynamicFormSaveValueSpec = Pick<
IDynamicFormItemSchema,
'default' | 'name' | 'type'
>;
/**
* Build the value snapshot emitted to parent forms for persistence.
* Only single-line string fields trim surrounding whitespace; multiline text
* and every other dynamic form field type preserve their original values.
*/
export function normalizeDynamicFormValuesForSave(
specs: readonly DynamicFormSaveValueSpec[],
formValues: Record<string, unknown>,
): Record<string, unknown> {
return specs.reduce<Record<string, unknown>>((values, spec) => {
const value = formValues[spec.name] ?? spec.default;
values[spec.name] =
spec.type === 'string' && typeof value === 'string'
? value.trim()
: value;
return values;
}, {});
}
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/form';
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import { extractI18nObject } from '@/i18n/I18nProvider';
/**
@@ -150,12 +151,9 @@ export default function N8nAuthFormComponent({
// Emit initial form values on mount so the parent form's
// initializedStagesRef registers this stage (matches DynamicFormComponent).
const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, string>,
const initialFinalValues = normalizeDynamicFormValuesForSave(
itemConfigList,
formValues as Record<string, unknown>,
);
onSubmitRef.current?.(initialFinalValues);
previousInitialValues.current = initialFinalValues as Record<
@@ -171,12 +169,9 @@ export default function N8nAuthFormComponent({
// 获取完整的表单值,确保包含所有默认值
const formValues = form.getValues();
const finalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, string>,
const finalValues = normalizeDynamicFormValuesForSave(
itemConfigList,
formValues as Record<string, unknown>,
);
onSubmitRef.current?.(finalValues);
@@ -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,
@@ -35,6 +39,7 @@ import {
Bot,
Workflow,
ListTree,
UsersRound,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
@@ -60,6 +65,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,
@@ -384,6 +392,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,
@@ -517,6 +530,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}>
@@ -556,7 +572,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';
@@ -845,6 +862,7 @@ function NavItems({
{itemIsPluginType && !item.debug && (
<PluginItemMenu
item={item}
canManage={canManageResources}
onUpdate={() => handlePluginUpdate(item)}
onDelete={() => handlePluginDelete(item)}
/>
@@ -1149,7 +1167,7 @@ function NavItems({
<span>{t('agents.groupByKindShort')}</span>
</button>
)}
{isExtensionsCategory && (
{isExtensionsCategory && canOperateRuntime && (
<button
type="button"
title={t('plugins.groupByType')}
@@ -1440,10 +1458,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;
}) {
@@ -1454,6 +1474,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) : '';
@@ -1494,7 +1516,7 @@ function PluginItemMenu({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
{isMarketplace && (
{canManage && isMarketplace && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -1523,16 +1545,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>
);
@@ -1722,6 +1746,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');
@@ -1765,6 +1790,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());
@@ -1788,10 +1826,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) {
@@ -1930,6 +1964,7 @@ export default function HomeSidebar({
}
function handleLogout() {
clearUserInfo();
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
window.location.href = '/login';
@@ -1990,6 +2025,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">
@@ -2058,18 +2097,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>
@@ -2158,6 +2199,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>
);
}