[verified] fix: harden OSS and Cloud workspace UI (#2387)

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-08-03 13:27:38 +08:00
committed by GitHub
parent 1e6e4c0ca7
commit 3101c9be6a
9 changed files with 378 additions and 60 deletions
@@ -396,10 +396,9 @@ export default function BotForm({
<form
id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-6"
aria-busy={isLoading}
>
<fieldset className="contents" disabled={isLoading}>
<fieldset className="space-y-6" disabled={isLoading}>
{/* Card 1: Basic Information */}
<Card>
<CardHeader>
@@ -8,6 +8,7 @@ import {
clearUserInfo,
getCloudServiceClientSync,
useCurrentWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import {
@@ -32,7 +33,6 @@ import {
Zap,
FilePlus2,
Sparkles,
HardDrive,
Server,
Puzzle,
RefreshCcw,
@@ -1637,6 +1637,13 @@ export default function HomeSidebar({
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap();
const showWorkspaceSwitcher =
workspaces.length > 1 ||
currentWorkspace?.workspace.source === 'cloud_projection';
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' &&
currentWorkspace?.permissions.includes('audit.view');
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
@@ -1915,9 +1922,11 @@ 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>
{showWorkspaceSwitcher && (
<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">
@@ -2098,15 +2107,16 @@ export default function HomeSidebar({
<UsersRound />
{t('workspace.settings')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
openSettings('storageAnalysis');
}}
>
<HardDrive />
{t('storageAnalysis.title')}
</DropdownMenuItem>
{canViewStorageAnalysis && (
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
openSettings('storageAnalysis');
}}
>
{t('storageAnalysis.title')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
@@ -133,12 +133,14 @@ export default function SettingsDialog({
const permissions = currentWorkspace?.permissions ?? [];
const canManageApiKeys = permissions.includes('api_key.manage');
const canViewAudit = permissions.includes('audit.view');
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
const navItems = allNavItems.filter((item) => {
if (item.id === 'apiIntegration') {
return canManageApiKeys;
}
if (item.id === 'storageAnalysis') {
return canViewAudit;
return canViewStorageAnalysis;
}
return true;
});
@@ -146,11 +148,17 @@ export default function SettingsDialog({
useEffect(() => {
const forbiddenSection =
(section === 'apiIntegration' && !canManageApiKeys) ||
(section === 'storageAnalysis' && !canViewAudit);
(section === 'storageAnalysis' && !canViewStorageAnalysis);
if (open && forbiddenSection) {
onSectionChange('workspace');
}
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]);
}, [
canManageApiKeys,
canViewStorageAnalysis,
open,
section,
onSectionChange,
]);
const activeItem = navItems.find((item) => item.id === section);
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
@@ -256,7 +264,7 @@ export default function SettingsDialog({
active={open && section === 'apiIntegration'}
/>
)}
{section === 'storageAnalysis' && (
{section === 'storageAnalysis' && canViewStorageAnalysis && (
<StorageAnalysisPanel
active={open && section === 'storageAnalysis'}
/>
+28 -17
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/components/providers/theme-provider';
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
/**
* Plugin page that renders a plugin-provided HTML page in an iframe.
@@ -80,11 +81,15 @@ function PluginPageIframe({
pageId: string;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loading, setLoading] = useState(true);
const [loadedAssetUrl, setLoadedAssetUrl] = useState('');
const { resolvedTheme } = useTheme();
const { i18n } = useTranslation();
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
const { t, i18n } = useTranslation();
const { url: assetUrl, error: assetError } = useAuthenticatedPluginAsset(
author,
pluginName,
pagePath,
);
const loading = !assetUrl || loadedAssetUrl !== assetUrl;
// Send context (theme + language) to iframe
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
@@ -170,23 +175,29 @@ function PluginPageIframe({
return (
<div className="flex flex-col h-full w-full">
{loading && (
{assetError ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t('plugins.loadFailed')}
</div>
) : loading || !assetUrl ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
) : null}
{!assetError && assetUrl && (
<iframe
ref={iframeRef}
src={assetUrl}
className="flex-1 w-full border-0 rounded-md"
style={{ display: loading ? 'none' : 'block' }}
onLoad={() => {
setLoadedAssetUrl(assetUrl);
sendContext();
}}
sandbox="allow-scripts allow-forms"
title={`${author}/${pluginName} - ${pagePath}`}
/>
)}
<iframe
ref={iframeRef}
src={assetUrl}
className="flex-1 w-full border-0 rounded-md"
style={{ display: loading ? 'none' : 'block' }}
onLoad={() => {
setLoading(false);
sendContext();
}}
sandbox="allow-scripts allow-forms"
title={`${author}/${pluginName} - ${pagePath}`}
/>
</div>
);
}
+52 -20
View File
@@ -1,41 +1,63 @@
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useCurrentWorkspace } from '@/app/infra/http';
type AuthenticatedResourceState = {
key: string;
url: string;
error: boolean;
};
const EMPTY_RESOURCE: AuthenticatedResourceState = {
key: '',
url: '',
error: false,
};
export function useAuthenticatedPluginIcon(
author: string,
name: string,
enabled = true,
): { url: string; error: boolean } {
const [url, setURL] = useState('');
const [error, setError] = useState(false);
const [resource, setResource] =
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
const currentWorkspace = useCurrentWorkspace();
const workspaceUuid = currentWorkspace?.workspace.uuid;
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}`;
useEffect(() => {
if (!enabled) {
setURL('');
setError(false);
setResource({ key: resourceKey, url: '', error: false });
return;
}
let active = true;
let objectURL = '';
setURL('');
setError(false);
setResource({ key: resourceKey, url: '', error: false });
httpClient
.getAuthenticatedPluginIconURL(author, name)
.then((nextURL) => {
objectURL = nextURL;
if (active) setURL(nextURL);
else URL.revokeObjectURL(nextURL);
if (active) {
setResource({ key: resourceKey, url: nextURL, error: false });
} else {
URL.revokeObjectURL(nextURL);
}
})
.catch(() => {
if (active) setError(true);
if (active) {
setResource({ key: resourceKey, url: '', error: true });
}
});
return () => {
active = false;
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [author, enabled, name]);
}, [author, enabled, name, resourceKey]);
return { url, error };
return {
url: resource.key === resourceKey ? resource.url : '',
error: resource.key === resourceKey && resource.error,
};
}
export function useAuthenticatedPluginAsset(
@@ -43,29 +65,39 @@ export function useAuthenticatedPluginAsset(
name: string,
filepath: string,
): { url: string; error: boolean } {
const [url, setURL] = useState('');
const [error, setError] = useState(false);
const [resource, setResource] =
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
const currentWorkspace = useCurrentWorkspace();
const workspaceUuid = currentWorkspace?.workspace.uuid;
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}/${filepath}`;
useEffect(() => {
let active = true;
let objectURL = '';
setURL('');
setError(false);
setResource({ key: resourceKey, url: '', error: false });
httpClient
.getAuthenticatedPluginAssetURL(author, name, filepath)
.then((nextURL) => {
objectURL = nextURL;
if (active) setURL(nextURL);
else URL.revokeObjectURL(nextURL);
if (active) {
setResource({ key: resourceKey, url: nextURL, error: false });
} else {
URL.revokeObjectURL(nextURL);
}
})
.catch(() => {
if (active) setError(true);
if (active) {
setResource({ key: resourceKey, url: '', error: true });
}
});
return () => {
active = false;
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [author, name, filepath]);
}, [author, name, filepath, resourceKey]);
return { url, error };
return {
url: resource.key === resourceKey ? resource.url : '',
error: resource.key === resourceKey && resource.error,
};
}