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
|
<form
|
||||||
id="bot-form"
|
id="bot-form"
|
||||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||||
className="space-y-6"
|
|
||||||
aria-busy={isLoading}
|
aria-busy={isLoading}
|
||||||
>
|
>
|
||||||
<fieldset className="contents" disabled={isLoading}>
|
<fieldset className="space-y-6" disabled={isLoading}>
|
||||||
{/* Card 1: Basic Information */}
|
{/* Card 1: Basic Information */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
clearUserInfo,
|
clearUserInfo,
|
||||||
getCloudServiceClientSync,
|
getCloudServiceClientSync,
|
||||||
useCurrentWorkspace,
|
useCurrentWorkspace,
|
||||||
|
useWorkspaceBootstrap,
|
||||||
} from '@/app/infra/http';
|
} from '@/app/infra/http';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,6 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
FilePlus2,
|
FilePlus2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
HardDrive,
|
|
||||||
Server,
|
Server,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
@@ -1637,6 +1637,13 @@ export default function HomeSidebar({
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentWorkspace = useCurrentWorkspace();
|
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 [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [settingsSection, setSettingsSection] =
|
const [settingsSection, setSettingsSection] =
|
||||||
useState<SettingsSection>('models');
|
useState<SettingsSection>('models');
|
||||||
@@ -1915,9 +1922,11 @@ export default function HomeSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
{showWorkspaceSwitcher && (
|
||||||
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||||
</div>
|
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Navigation items grouped by section */}
|
{/* Navigation items grouped by section */}
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
@@ -2098,15 +2107,16 @@ export default function HomeSidebar({
|
|||||||
<UsersRound />
|
<UsersRound />
|
||||||
{t('workspace.settings')}
|
{t('workspace.settings')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
{canViewStorageAnalysis && (
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
setUserMenuOpen(false);
|
onClick={() => {
|
||||||
openSettings('storageAnalysis');
|
setUserMenuOpen(false);
|
||||||
}}
|
openSettings('storageAnalysis');
|
||||||
>
|
}}
|
||||||
<HardDrive />
|
>
|
||||||
{t('storageAnalysis.title')}
|
{t('storageAnalysis.title')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
|
|||||||
@@ -133,12 +133,14 @@ export default function SettingsDialog({
|
|||||||
const permissions = currentWorkspace?.permissions ?? [];
|
const permissions = currentWorkspace?.permissions ?? [];
|
||||||
const canManageApiKeys = permissions.includes('api_key.manage');
|
const canManageApiKeys = permissions.includes('api_key.manage');
|
||||||
const canViewAudit = permissions.includes('audit.view');
|
const canViewAudit = permissions.includes('audit.view');
|
||||||
|
const canViewStorageAnalysis =
|
||||||
|
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
|
||||||
const navItems = allNavItems.filter((item) => {
|
const navItems = allNavItems.filter((item) => {
|
||||||
if (item.id === 'apiIntegration') {
|
if (item.id === 'apiIntegration') {
|
||||||
return canManageApiKeys;
|
return canManageApiKeys;
|
||||||
}
|
}
|
||||||
if (item.id === 'storageAnalysis') {
|
if (item.id === 'storageAnalysis') {
|
||||||
return canViewAudit;
|
return canViewStorageAnalysis;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -146,11 +148,17 @@ export default function SettingsDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const forbiddenSection =
|
const forbiddenSection =
|
||||||
(section === 'apiIntegration' && !canManageApiKeys) ||
|
(section === 'apiIntegration' && !canManageApiKeys) ||
|
||||||
(section === 'storageAnalysis' && !canViewAudit);
|
(section === 'storageAnalysis' && !canViewStorageAnalysis);
|
||||||
if (open && forbiddenSection) {
|
if (open && forbiddenSection) {
|
||||||
onSectionChange('workspace');
|
onSectionChange('workspace');
|
||||||
}
|
}
|
||||||
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]);
|
}, [
|
||||||
|
canManageApiKeys,
|
||||||
|
canViewStorageAnalysis,
|
||||||
|
open,
|
||||||
|
section,
|
||||||
|
onSectionChange,
|
||||||
|
]);
|
||||||
|
|
||||||
const activeItem = navItems.find((item) => item.id === section);
|
const activeItem = navItems.find((item) => item.id === section);
|
||||||
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
||||||
@@ -256,7 +264,7 @@ export default function SettingsDialog({
|
|||||||
active={open && section === 'apiIntegration'}
|
active={open && section === 'apiIntegration'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{section === 'storageAnalysis' && (
|
{section === 'storageAnalysis' && canViewStorageAnalysis && (
|
||||||
<StorageAnalysisPanel
|
<StorageAnalysisPanel
|
||||||
active={open && section === 'storageAnalysis'}
|
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 { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useTheme } from '@/components/providers/theme-provider';
|
import { useTheme } from '@/components/providers/theme-provider';
|
||||||
|
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin page that renders a plugin-provided HTML page in an iframe.
|
* Plugin page that renders a plugin-provided HTML page in an iframe.
|
||||||
@@ -80,11 +81,15 @@ function PluginPageIframe({
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
}) {
|
}) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loadedAssetUrl, setLoadedAssetUrl] = useState('');
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const { i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { url: assetUrl, error: assetError } = useAuthenticatedPluginAsset(
|
||||||
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
|
author,
|
||||||
|
pluginName,
|
||||||
|
pagePath,
|
||||||
|
);
|
||||||
|
const loading = !assetUrl || loadedAssetUrl !== assetUrl;
|
||||||
|
|
||||||
// Send context (theme + language) to iframe
|
// Send context (theme + language) to iframe
|
||||||
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
|
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
|
||||||
@@ -170,23 +175,29 @@ function PluginPageIframe({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<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">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,63 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
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(
|
export function useAuthenticatedPluginIcon(
|
||||||
author: string,
|
author: string,
|
||||||
name: string,
|
name: string,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
): { url: string; error: boolean } {
|
): { url: string; error: boolean } {
|
||||||
const [url, setURL] = useState('');
|
const [resource, setResource] =
|
||||||
const [error, setError] = useState(false);
|
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
|
||||||
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
|
const workspaceUuid = currentWorkspace?.workspace.uuid;
|
||||||
|
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
setURL('');
|
setResource({ key: resourceKey, url: '', error: false });
|
||||||
setError(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
let objectURL = '';
|
let objectURL = '';
|
||||||
setURL('');
|
setResource({ key: resourceKey, url: '', error: false });
|
||||||
setError(false);
|
|
||||||
httpClient
|
httpClient
|
||||||
.getAuthenticatedPluginIconURL(author, name)
|
.getAuthenticatedPluginIconURL(author, name)
|
||||||
.then((nextURL) => {
|
.then((nextURL) => {
|
||||||
objectURL = nextURL;
|
objectURL = nextURL;
|
||||||
if (active) setURL(nextURL);
|
if (active) {
|
||||||
else URL.revokeObjectURL(nextURL);
|
setResource({ key: resourceKey, url: nextURL, error: false });
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(nextURL);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (active) setError(true);
|
if (active) {
|
||||||
|
setResource({ key: resourceKey, url: '', error: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
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(
|
export function useAuthenticatedPluginAsset(
|
||||||
@@ -43,29 +65,39 @@ export function useAuthenticatedPluginAsset(
|
|||||||
name: string,
|
name: string,
|
||||||
filepath: string,
|
filepath: string,
|
||||||
): { url: string; error: boolean } {
|
): { url: string; error: boolean } {
|
||||||
const [url, setURL] = useState('');
|
const [resource, setResource] =
|
||||||
const [error, setError] = useState(false);
|
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
|
||||||
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
|
const workspaceUuid = currentWorkspace?.workspace.uuid;
|
||||||
|
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}/${filepath}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
let objectURL = '';
|
let objectURL = '';
|
||||||
setURL('');
|
setResource({ key: resourceKey, url: '', error: false });
|
||||||
setError(false);
|
|
||||||
httpClient
|
httpClient
|
||||||
.getAuthenticatedPluginAssetURL(author, name, filepath)
|
.getAuthenticatedPluginAssetURL(author, name, filepath)
|
||||||
.then((nextURL) => {
|
.then((nextURL) => {
|
||||||
objectURL = nextURL;
|
objectURL = nextURL;
|
||||||
if (active) setURL(nextURL);
|
if (active) {
|
||||||
else URL.revokeObjectURL(nextURL);
|
setResource({ key: resourceKey, url: nextURL, error: false });
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(nextURL);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (active) setError(true);
|
if (active) {
|
||||||
|
setResource({ key: resourceKey, url: '', error: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
installLangBotApiMocks,
|
||||||
|
makeWorkspaceEntry,
|
||||||
|
} from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
function wrapped(data: unknown) {
|
||||||
|
return JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'ok',
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Cloud never exposes or requests storage analysis', async ({ page }) => {
|
||||||
|
const workspace = makeWorkspaceEntry(
|
||||||
|
'workspace-cloud',
|
||||||
|
'Cloud Workspace',
|
||||||
|
'cloud_projection',
|
||||||
|
);
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
|
workspaces: [workspace],
|
||||||
|
});
|
||||||
|
await page.route(
|
||||||
|
/\/api\/v1\/workspaces\/workspace-cloud\/(members|invitations)$/,
|
||||||
|
async (route) => {
|
||||||
|
const collection = route.request().url().endsWith('/members')
|
||||||
|
? 'members'
|
||||||
|
: 'invitations';
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped({ [collection]: [] }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let storageAnalysisRequests = 0;
|
||||||
|
await page.route('**/api/v1/system/storage-analysis', async (route) => {
|
||||||
|
storageAnalysisRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/home/bots');
|
||||||
|
await page.getByRole('button', { name: /admin@example\.com/i }).click();
|
||||||
|
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/home/bots?action=showStorageAnalysis');
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Workspace' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(storageAnalysisRequests).toBe(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
installLangBotApiMocks,
|
||||||
|
makeWorkspaceEntry,
|
||||||
|
} from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
function wrapped(data: unknown) {
|
||||||
|
return JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'ok',
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loads a Cloud plugin page through the authenticated asset route', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = makeWorkspaceEntry(
|
||||||
|
'workspace-cloud',
|
||||||
|
'Cloud Workspace',
|
||||||
|
'cloud_projection',
|
||||||
|
);
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
|
workspaces: [workspace],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/v1/plugins', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
install_source: 'marketplace',
|
||||||
|
install_info: {},
|
||||||
|
debug: false,
|
||||||
|
manifest: {
|
||||||
|
manifest: {
|
||||||
|
metadata: {
|
||||||
|
author: 'langbot-team',
|
||||||
|
name: 'LangRAG',
|
||||||
|
version: '0.1.9',
|
||||||
|
label: { en_US: 'LangRAG', zh_Hans: 'LangRAG' },
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
id: 'observability',
|
||||||
|
path: 'components/pages/observability.html',
|
||||||
|
label: { en_US: 'Observability', zh_Hans: '观测面板' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let authenticatedAssetRequests = 0;
|
||||||
|
await page.route(
|
||||||
|
'**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**',
|
||||||
|
async (route) => {
|
||||||
|
authenticatedAssetRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.frameLocator('iframe')
|
||||||
|
.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
||||||
|
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webRoot = path.resolve(currentDirectory, '../..');
|
||||||
|
|
||||||
|
function readSource(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeSidebarSource = readSource(
|
||||||
|
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
|
||||||
|
);
|
||||||
|
const botFormSource = readSource(
|
||||||
|
'src/app/home/bots/components/bot-form/BotForm.tsx',
|
||||||
|
);
|
||||||
|
const kbFormSource = readSource(
|
||||||
|
'src/app/home/knowledge/components/kb-form/KBForm.tsx',
|
||||||
|
);
|
||||||
|
const settingsDialogSource = readSource(
|
||||||
|
'src/app/home/components/settings-dialog/SettingsDialog.tsx',
|
||||||
|
);
|
||||||
|
const pluginPageSource = readSource('src/app/home/plugin-pages/page.tsx');
|
||||||
|
const authenticatedPluginResourceSource = readSource(
|
||||||
|
'src/hooks/useAuthenticatedPluginResource.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('hides the entire workspace switcher slot for a singleton local workspace', () => {
|
||||||
|
assert.match(homeSidebarSource, /useWorkspaceBootstrap/);
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>\s*<WorkspaceSwitcher/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps bot cards at the same vertical spacing as knowledge-base cards', () => {
|
||||||
|
assert.match(
|
||||||
|
botFormSource,
|
||||||
|
/<fieldset className="space-y-6" disabled=\{isLoading\}>/,
|
||||||
|
);
|
||||||
|
assert.match(kbFormSource, /<form[\s\S]*?className="space-y-6"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not expose storage analysis in Cloud settings or via a deep link', () => {
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/canViewStorageAnalysis\s*&&\s*\(\s*<DropdownMenuItem[\s\S]*?openSettings\('storageAnalysis'\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsDialogSource,
|
||||||
|
/const canViewStorageAnalysis\s*=\s*currentWorkspace\?\.workspace\.source\s*!==\s*'cloud_projection'\s*&&\s*canViewAudit/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsDialogSource,
|
||||||
|
/item\.id === 'storageAnalysis'[\s\S]*?return canViewStorageAnalysis/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsDialogSource,
|
||||||
|
/section === 'storageAnalysis' && !canViewStorageAnalysis/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsDialogSource,
|
||||||
|
/section === 'storageAnalysis' &&\s*canViewStorageAnalysis &&\s*\(\s*<StorageAnalysisPanel/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads plugin pages through the authenticated Workspace-scoped asset route', () => {
|
||||||
|
assert.match(pluginPageSource, /useAuthenticatedPluginAsset/);
|
||||||
|
assert.match(
|
||||||
|
pluginPageSource,
|
||||||
|
/useAuthenticatedPluginAsset\(\s*author,\s*pluginName,\s*pagePath,?\s*\)/,
|
||||||
|
);
|
||||||
|
assert.match(pluginPageSource, /src=\{assetUrl\}/);
|
||||||
|
assert.doesNotMatch(pluginPageSource, /getPluginAssetURL\(/);
|
||||||
|
assert.match(pluginPageSource, /plugins\.loadFailed/);
|
||||||
|
assert.match(pluginPageSource, /loadedAssetUrl !== assetUrl/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revokes and reloads authenticated plugin resources when the Workspace changes', () => {
|
||||||
|
assert.match(authenticatedPluginResourceSource, /useCurrentWorkspace/);
|
||||||
|
assert.match(
|
||||||
|
authenticatedPluginResourceSource,
|
||||||
|
/const workspaceUuid = currentWorkspace\?\.workspace\.uuid;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
authenticatedPluginResourceSource,
|
||||||
|
/\[author, name, filepath, resourceKey\]/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
authenticatedPluginResourceSource,
|
||||||
|
/resource\.key === resourceKey \? resource\.url : ''/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -50,15 +50,22 @@ test('places WorkspaceSwitcher between the sidebar header and Home navigation',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it is the only workspace', () => {
|
test('hides WorkspaceSwitcher for the singleton local OSS workspace and keeps it for Cloud or multiple workspaces', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
workspaceSwitcherSource,
|
workspaceSwitcherSource,
|
||||||
/if \(!currentWorkspace\) return null;/,
|
/if \(!currentWorkspace\) return null;/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(workspaceSwitcherSource, /workspaces\.length\s*<=\s*1/);
|
assert.match(
|
||||||
assert.doesNotMatch(
|
|
||||||
homeSidebarSource,
|
homeSidebarSource,
|
||||||
/currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'[\s\S]{0,200}<WorkspaceSwitcher/,
|
/const workspaces = useWorkspaceBootstrap\(\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/const showWorkspaceSwitcher\s*=\s*workspaces\.length\s*>\s*1\s*\|\|\s*currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
homeSidebarSource,
|
||||||
|
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>[\s\S]*?<WorkspaceSwitcher/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user