diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index 1fc6114bca5..8c6acc78866 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -48,14 +48,18 @@ const mockUsePathname = mockNavigation.usePathname const mockUseRouter = mockNavigation.useRouter const mockFetchAppDetailDirect = vi.mocked(fetchAppDetailDirect) -const createAppDetail = (overrides: Partial = {}) => +type AppDetailFixture = App & { + bound_agent_id?: string | null +} + +const createAppDetail = (overrides: Partial = {}) => ({ id: 'app-1', name: 'Demo App', mode: AppModeEnum.WORKFLOW, permission_keys: [AppACLPermission.ViewLayout, AppACLPermission.Monitor], ...overrides, - }) as App + }) as AppDetailFixture const waitForAppContent = async () => { await waitFor(() => { @@ -424,6 +428,52 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail?.id).toBe('app-1') }) + it('should redirect Agent app access config URLs to the Agent configure page', async () => { + mockPathname = '/app/app-1/access-config' + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ + mode: AppModeEnum.AGENT, + bound_agent_id: 'agent-1', + permission_keys: [AppACLPermission.AccessConfig], + }), + ) + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/agents/agent-1/configure') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + + it('should keep Agent app access config content hidden while redirecting cached app data', async () => { + mockPathname = '/app/app-1/access-config' + useStore.getState().setAppDetail( + createAppDetail({ + mode: AppModeEnum.AGENT, + bound_agent_id: 'agent-1', + permission_keys: [AppACLPermission.AccessConfig], + }), + ) + + render( + +
App page content
+
, + ) + + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/agents/agent-1/configure') + }) + expect(mockFetchAppDetailDirect).not.toHaveBeenCalled() + }) + it('should redirect access config pages when RBAC is disabled', async () => { mockIsRbacEnabled = false mockPathname = '/app/app-1/access-config' diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 24a254fe57e..7f9f6973ca8 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -78,6 +78,8 @@ const AppDetailLayout: FC = (props) => { appDetail?.id === appId ? appDetail : appDetailRes?.id === appId ? appDetailRes : null const pageTitle = appDetailPageTitle(pathname, t) const appName = routeAppDetail?.id === appId ? routeAppDetail.name : undefined + const shouldBlockAgentResourceAccess = + routeAppDetail?.mode === AppModeEnum.AGENT && pathname.endsWith('/access-config') useDocumentTitle(`${pageTitle} ยท ${appName || t(($) => $['menus.appDetail'], { ns: 'common' })}`) @@ -145,7 +147,8 @@ const AppDetailLayout: FC = (props) => { (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) || (isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) || (isOverviewPath && !appACLCapabilities.canMonitor) || - (isAccessConfigPath && !appACLCapabilities.canAccessConfig) || + (isAccessConfigPath && + (routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) || (isDeployPath && (routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy)) ) { @@ -194,27 +197,28 @@ const AppDetailLayout: FC = (props) => { ]) const isWorkflowPage = pathname.endsWith('/workflow') - const content = !appDetail ? ( -
- -
- ) : ( -
+ const content = + !appDetail || shouldBlockAgentResourceAccess ? ( +
+ +
+ ) : (
- {children} +
+ {children} +
-
- ) + ) return (
diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index 3b8b116c0d0..e84d925dbdc 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -1,5 +1,6 @@ import { screen } from '@testing-library/react' import { renderWithConsoleQuery } from '@/test/console/query-data' +import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import AppDetailSection from '../app-detail-section' @@ -237,20 +238,37 @@ describe('AppDetailSection', () => { expect(screen.queryByRole('link', { name: 'common.appMenus.deploy' })).not.toBeInTheDocument() }) - it('should render resource access navigation when app access config permission is granted', () => { + it.each([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])( + 'should render resource access navigation for %s apps when app access config permission is granted', + (mode) => { + // Arrange + mockAppMode = mode + mockAppPermissionKeys = [AppACLPermission.AccessConfig] + + // Act + render() + + // Assert + expect( + screen.getByRole('link', { name: 'common.settings.resourceAccess' }), + ).toHaveAttribute('href', '/app/app-1/access-config') + expect( + screen.queryByRole('link', { name: 'common.appMenus.overview' }), + ).not.toBeInTheDocument() + }, + ) + + it('should hide resource access navigation for Agent apps', () => { // Arrange + mockAppMode = AppModeEnum.AGENT mockAppPermissionKeys = [AppACLPermission.AccessConfig] // Act render() // Assert - expect(screen.getByRole('link', { name: 'common.settings.resourceAccess' })).toHaveAttribute( - 'href', - '/app/app-1/access-config', - ) expect( - screen.queryByRole('link', { name: 'common.appMenus.overview' }), + screen.queryByRole('link', { name: 'common.settings.resourceAccess' }), ).not.toBeInTheDocument() }) diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 505c8c15a45..3176e2cac21 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -101,6 +101,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW const supportsAnnotations = appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION + const supportsResourceAccess = appDetail.mode !== AppModeEnum.AGENT const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, { currentUserId, resourceMaintainer: appDetail.maintainer, @@ -165,7 +166,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { }, ] : []), - ...(appACLCapabilities.canAccessConfig + ...(supportsResourceAccess && appACLCapabilities.canAccessConfig ? [ { name: t(($) => $['settings.resourceAccess'], { ns: 'common' }), diff --git a/web/app/components/app/access-config/__tests__/index.spec.tsx b/web/app/components/app/access-config/__tests__/index.spec.tsx index 98808f656a1..07956cad523 100644 --- a/web/app/components/app/access-config/__tests__/index.spec.tsx +++ b/web/app/components/app/access-config/__tests__/index.spec.tsx @@ -8,6 +8,7 @@ import { useAppUserAccessSettings, } from '@/service/access-control/use-app-access-config' import { renderWithConsoleQuery } from '@/test/console/query-data' +import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import AppAccessConfigPage from '../index' @@ -365,6 +366,23 @@ describe('AppAccessConfigPage', () => { expect(useAppUserAccessSettings).not.toHaveBeenCalled() }) + it('should not mount access config data hooks for Agent apps', () => { + useStore.setState({ + appDetail: { + id: 'app-1', + mode: AppModeEnum.AGENT, + maintainer: 'account-1', + permission_keys: [AppACLPermission.AccessConfig], + } as unknown as NonNullable['appDetail']>, + }) + + render() + + expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument() + expect(useAppAccessRules).not.toHaveBeenCalled() + expect(useAppUserAccessSettings).not.toHaveBeenCalled() + }) + it('should allow the maintainer with app management workspace permission', () => { mockConsoleState.userProfile = { id: 'account-1' } mockConsoleState.workspacePermissionKeys = ['app.create_and_management'] diff --git a/web/app/components/app/access-config/index.tsx b/web/app/components/app/access-config/index.tsx index de96391e682..1452615d87b 100644 --- a/web/app/components/app/access-config/index.tsx +++ b/web/app/components/app/access-config/index.tsx @@ -22,6 +22,7 @@ import { useUpdateAppAutomaticIncludeWorkspaceMembers, useUpdateAppUserAccessSettings, } from '@/service/access-control/use-app-access-config' +import { AppModeEnum } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' type AppAccessConfigPageProps = { @@ -257,7 +258,13 @@ const AppAccessConfigPage = ({ appId }: AppAccessConfigPageProps) => { ], ) - if (!appDetail || appDetail.id !== appId || !appACLCapabilities.canAccessConfig) return null + if ( + !appDetail || + appDetail.id !== appId || + appDetail.mode === AppModeEnum.AGENT || + !appACLCapabilities.canAccessConfig + ) + return null return }