diff --git a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx
index f26723d0199..761d8c1c8bf 100644
--- a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx
+++ b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx
@@ -47,14 +47,14 @@ vi.mock('@/service/client', () => ({
const mockUseQuery = vi.mocked(useQuery)
-function renderGuard(children: ReactNode) {
+function renderGuard(children: ReactNode, systemFeatures: { enable_app_deploy?: boolean } = {}) {
return renderWithSystemFeatures(
{children}
,
{
systemFeatures: {
- enable_app_deploy: true,
+ enable_app_deploy: systemFeatures.enable_app_deploy ?? true,
},
},
)
@@ -95,6 +95,16 @@ describe('RoleRouteGuard', () => {
expect(mocks.redirect).toHaveBeenCalledWith('/datasets')
})
+ it('should allow dataset operator on routes outside the guarded list', () => {
+ mockPathname = '/new-route'
+ setCurrentWorkspaceQuery({ role: 'dataset_operator' })
+
+ renderGuard(
content
)
+
+ expect(screen.getByText('content')).toBeInTheDocument()
+ expect(mocks.redirect).not.toHaveBeenCalled()
+ })
+
it('should redirect dataset operator on deployments routes', () => {
mockPathname = '/deployments/create'
setCurrentWorkspaceQuery({ role: 'dataset_operator' })
@@ -104,6 +114,24 @@ describe('RoleRouteGuard', () => {
expect(mocks.redirect).toHaveBeenCalledWith('/datasets')
})
+ it('should prefer app deploy redirect when app deploy is disabled', () => {
+ mockPathname = '/deployments/create'
+ setCurrentWorkspaceQuery({ role: 'dataset_operator' })
+
+ expect(() => renderGuard(content
, { enable_app_deploy: false })).toThrow('NEXT_REDIRECT:/')
+
+ expect(mocks.redirect).toHaveBeenCalledWith('/')
+ })
+
+ it('should redirect app deploy routes when app deploy is disabled', () => {
+ mockPathname = '/deployments/create'
+ setCurrentWorkspaceQuery({ role: 'editor' })
+
+ expect(() => renderGuard(content
, { enable_app_deploy: false })).toThrow('NEXT_REDIRECT:/')
+
+ expect(mocks.redirect).toHaveBeenCalledWith('/')
+ })
+
it('should allow dataset operator on non-guarded routes', () => {
mockPathname = '/plugins'
setCurrentWorkspaceQuery({ role: 'dataset_operator' })
diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx
index 0fee1d72f08..fc81c091716 100644
--- a/web/app/(commonLayout)/role-route-guard.tsx
+++ b/web/app/(commonLayout)/role-route-guard.tsx
@@ -30,7 +30,7 @@ export function RoleRouteGuard({ children }: { children: ReactNode }) {
return
if (shouldRedirectAppDeploy)
- redirect('/apps')
+ redirect('/')
if (shouldRedirectDatasetOperator)
redirect('/datasets')
diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx
index 07d66c3a2d8..aa1495b08cc 100644
--- a/web/app/components/main-nav/index.tsx
+++ b/web/app/components/main-nav/index.tsx
@@ -15,7 +15,6 @@ import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
import { useStore as useAppStore } from '@/app/components/app/store'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
-import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { useAppContext } from '@/context/app-context'
import { AgentDetailSection, AgentDetailTop } from '@/features/agent-v2/agent-detail/navigation'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
@@ -29,6 +28,7 @@ import MainNavLink from './components/nav-link'
import { MainNavSearchButton } from './components/search-button'
import WebAppsSection from './components/web-apps-section'
import { WorkspaceCard } from './components/workspace-card'
+import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
@@ -185,73 +185,20 @@ const MainNav = ({
ignoreInputs: false,
})
- const navItems = useMemo(() => [
- ...(!isCurrentWorkspaceDatasetOperator
- ? [
- {
- href: '/',
- label: t('mainNav.home', { ns: 'common' }),
- active: (path: string) => path === '/' || path === '/explore/apps',
- icon: 'i-custom-vender-main-nav-home',
- activeIcon: 'i-custom-vender-main-nav-home-active',
- },
- {
- href: '/apps',
- label: t('menus.apps', { ns: 'common' }),
- active: (path: string) => path.startsWith('/apps') || path.startsWith('/app/') || path.startsWith('/snippets'),
- icon: 'i-custom-vender-main-nav-studio',
- activeIcon: 'i-custom-vender-main-nav-studio-active',
- },
- ...(agentV2Enabled
- ? [{
- href: '/roster',
- label: t('menus.roster', { ns: 'common' }),
- active: (path: string) => path.startsWith('/roster'),
- icon: 'i-custom-vender-main-nav-roster',
- activeIcon: 'i-custom-vender-main-nav-roster-active',
- }]
- : []),
- ]
- : []),
- ...((isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator)
- ? [
- {
- href: '/datasets',
- label: t('menus.datasets', { ns: 'common' }),
- active: (path: string) => path.startsWith('/datasets'),
- icon: 'i-custom-vender-main-nav-knowledge',
- activeIcon: 'i-custom-vender-main-nav-knowledge-active',
- },
- ]
- : []),
- ...(!isCurrentWorkspaceDatasetOperator
- ? [
- {
- href: buildIntegrationPath('provider'),
- label: t('mainNav.integrations', { ns: 'common' }),
- active: (path: string) => path.startsWith('/integrations') || path.startsWith('/tools'),
- icon: 'i-custom-vender-main-nav-integrations',
- activeIcon: 'i-custom-vender-main-nav-integrations-active',
- },
- ]
- : []),
- {
- href: '/marketplace',
- label: t('mainNav.marketplace', { ns: 'common' }),
- active: path => path.startsWith('/marketplace') || path.startsWith('/plugins'),
- icon: 'i-custom-vender-main-nav-marketplace',
- activeIcon: 'i-custom-vender-main-nav-marketplace-active',
- },
- ...(canUseAppDeploy
- ? [{
- href: '/deployments',
- label: t('menus.deployments', { ns: 'common' }),
- active: (path: string) => path.startsWith('/deployments'),
- icon: 'i-ri-rocket-line',
- activeIcon: 'i-ri-rocket-fill',
- }]
- : []),
- ], [agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor, t])
+ const navItems = useMemo(() => MAIN_NAV_ROUTES
+ .filter(route => isMainNavRouteVisible(route, {
+ agentV2Enabled,
+ canUseAppDeploy,
+ isCurrentWorkspaceDatasetOperator,
+ isCurrentWorkspaceEditor,
+ }))
+ .map(route => ({
+ href: route.href,
+ label: t(route.labelKey, { ns: 'common' }),
+ active: route.active,
+ icon: route.icon,
+ activeIcon: route.activeIcon,
+ })), [agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor, t])
const renderLogo = () => {
const appTitle = systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'
diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts
new file mode 100644
index 00000000000..e63419cf226
--- /dev/null
+++ b/web/app/components/main-nav/routes.ts
@@ -0,0 +1,108 @@
+import { buildIntegrationPath } from '@/app/components/integrations/routes'
+
+type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'editorOrDatasetOperator' | 'appDeployEditor'
+
+export type MainNavRouteConfig = {
+ key: string
+ href: string
+ labelKey: string
+ active: (pathname: string) => boolean
+ icon: string
+ activeIcon: string
+ visibility: MainNavRouteVisibility
+ feature?: 'agentV2'
+}
+
+export type MainNavRouteVisibilityOptions = {
+ agentV2Enabled: boolean
+ canUseAppDeploy: boolean
+ isCurrentWorkspaceDatasetOperator: boolean
+ isCurrentWorkspaceEditor: boolean
+}
+
+function isPathUnderRoute(pathname: string, route: string) {
+ return pathname === route || pathname.startsWith(`${route}/`)
+}
+
+export const MAIN_NAV_ROUTES = [
+ {
+ key: 'home',
+ href: '/',
+ labelKey: 'mainNav.home',
+ active: (path: string) => path === '/' || path === '/explore/apps',
+ icon: 'i-custom-vender-main-nav-home',
+ activeIcon: 'i-custom-vender-main-nav-home-active',
+ visibility: 'notDatasetOperator',
+ },
+ {
+ key: 'apps',
+ href: '/apps',
+ labelKey: 'menus.apps',
+ active: (path: string) => isPathUnderRoute(path, '/apps') || isPathUnderRoute(path, '/app') || isPathUnderRoute(path, '/snippets'),
+ icon: 'i-custom-vender-main-nav-studio',
+ activeIcon: 'i-custom-vender-main-nav-studio-active',
+ visibility: 'notDatasetOperator',
+ },
+ {
+ key: 'roster',
+ href: '/roster',
+ labelKey: 'menus.roster',
+ active: (path: string) => isPathUnderRoute(path, '/roster'),
+ icon: 'i-custom-vender-main-nav-roster',
+ activeIcon: 'i-custom-vender-main-nav-roster-active',
+ visibility: 'notDatasetOperator',
+ feature: 'agentV2',
+ },
+ {
+ key: 'datasets',
+ href: '/datasets',
+ labelKey: 'menus.datasets',
+ active: (path: string) => isPathUnderRoute(path, '/datasets'),
+ icon: 'i-custom-vender-main-nav-knowledge',
+ activeIcon: 'i-custom-vender-main-nav-knowledge-active',
+ visibility: 'editorOrDatasetOperator',
+ },
+ {
+ key: 'integrations',
+ href: buildIntegrationPath('provider'),
+ labelKey: 'mainNav.integrations',
+ active: (path: string) => isPathUnderRoute(path, '/integrations') || isPathUnderRoute(path, '/tools'),
+ icon: 'i-custom-vender-main-nav-integrations',
+ activeIcon: 'i-custom-vender-main-nav-integrations-active',
+ visibility: 'notDatasetOperator',
+ },
+ {
+ key: 'marketplace',
+ href: '/marketplace',
+ labelKey: 'mainNav.marketplace',
+ active: (path: string) => isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'),
+ icon: 'i-custom-vender-main-nav-marketplace',
+ activeIcon: 'i-custom-vender-main-nav-marketplace-active',
+ visibility: 'all',
+ },
+ {
+ key: 'deployments',
+ href: '/deployments',
+ labelKey: 'menus.deployments',
+ active: (path: string) => isPathUnderRoute(path, '/deployments'),
+ icon: 'i-ri-rocket-line',
+ activeIcon: 'i-ri-rocket-fill',
+ visibility: 'appDeployEditor',
+ },
+] as const satisfies readonly MainNavRouteConfig[]
+
+export function isMainNavRouteVisible(route: MainNavRouteConfig, options: MainNavRouteVisibilityOptions) {
+ if (route.feature === 'agentV2' && !options.agentV2Enabled)
+ return false
+
+ if (route.visibility === 'all')
+ return true
+
+ if (route.visibility === 'notDatasetOperator')
+ return !options.isCurrentWorkspaceDatasetOperator
+
+ if (route.visibility === 'editorOrDatasetOperator')
+ return options.isCurrentWorkspaceEditor || options.isCurrentWorkspaceDatasetOperator
+
+ return options.canUseAppDeploy
+}