refactor(web): centralize main nav route access (#37612)

This commit is contained in:
yyh 2026-06-18 14:47:22 +08:00 committed by GitHub
parent 7bfcf9185c
commit 762321751c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 154 additions and 71 deletions

View File

@ -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(
<RoleRouteGuard>
{children}
</RoleRouteGuard>,
{
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(<div>content</div>)
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(<div>content</div>, { 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(<div>content</div>, { 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' })

View File

@ -30,7 +30,7 @@ export function RoleRouteGuard({ children }: { children: ReactNode }) {
return <Loading type="app" />
if (shouldRedirectAppDeploy)
redirect('/apps')
redirect('/')
if (shouldRedirectDatasetOperator)
redirect('/datasets')

View File

@ -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<MainNavItem[]>(() => [
...(!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<MainNavItem[]>(() => 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'

View File

@ -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
}