mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
[verified] fix: harden OSS and Cloud workspace UI (#2387)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user