hide for ce

This commit is contained in:
Stephen Zhou 2026-05-12 19:28:46 +08:00
parent 86e88ef6c8
commit d3684f1542
No known key found for this signature in database
7 changed files with 106 additions and 45 deletions

View File

@ -159,6 +159,7 @@ class PluginManagerModel(BaseModel):
class SystemFeatureModel(BaseModel):
app_dsl_version: str = ""
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = ""
enable_marketplace: bool = False
@ -233,6 +234,7 @@ class FeatureService:
cls._fulfill_system_params_from_env(system_features)
if dify_config.ENTERPRISE_ENABLED:
system_features.enable_app_deploy = True
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False

View File

@ -291,6 +291,7 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.webapp_auth.enabled is True
assert result.enable_change_email is False
@ -377,6 +378,7 @@ class TestFeatureService:
# Ensure that data required for frontend rendering remains accessible.
# Branding should match the mock data
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.branding.application_title == "Test Enterprise"
assert result.branding.login_page_logo == "https://example.com/logo.png"
@ -424,6 +426,7 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify basic configuration
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True
@ -625,6 +628,7 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features are disabled
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True

View File

@ -1,5 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import RoleRouteGuard from './role-route-guard'
const mockReplace = vi.fn()
@ -34,6 +35,16 @@ const setAppContext = (overrides: Partial<AppContextMock> = {}) => {
})
}
const renderRoleRouteGuard = (systemFeatures: { enable_app_deploy?: boolean } = {}) =>
renderWithSystemFeatures(
(
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
),
{ systemFeatures },
)
describe('RoleRouteGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -46,11 +57,7 @@ describe('RoleRouteGuard', () => {
isLoadingCurrentWorkspace: true,
})
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderRoleRouteGuard()
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
@ -62,11 +69,7 @@ describe('RoleRouteGuard', () => {
isCurrentWorkspaceDatasetOperator: true,
})
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderRoleRouteGuard()
expect(screen.queryByText('content')).not.toBeInTheDocument()
await waitFor(() => {
@ -80,11 +83,7 @@ describe('RoleRouteGuard', () => {
isCurrentWorkspaceDatasetOperator: true,
})
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderRoleRouteGuard()
expect(screen.getByText('content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
@ -96,14 +95,30 @@ describe('RoleRouteGuard', () => {
isLoadingCurrentWorkspace: true,
})
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderRoleRouteGuard()
expect(screen.getByText('content')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should redirect deployments routes when app deploy is disabled', async () => {
mockPathname = '/deployments'
renderRoleRouteGuard({ enable_app_deploy: false })
expect(screen.queryByText('content')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should allow deployments routes when app deploy is enabled', () => {
mockPathname = '/deployments/app-1/overview'
renderRoleRouteGuard({ enable_app_deploy: true })
expect(screen.getByText('content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
})

View File

@ -1,10 +1,12 @@
'use client'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
@ -12,15 +14,19 @@ const isPathUnderRoute = (pathname: string, route: string) => pathname === route
export default function RoleRouteGuard({ children }: { children: ReactNode }) {
const { isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const pathname = usePathname()
const router = useRouter()
const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route))
const shouldRedirect = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
const shouldRedirectDatasetOperator = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy
const shouldRedirect = shouldRedirectDatasetOperator || shouldRedirectAppDeploy
const redirectPath = shouldRedirectAppDeploy ? '/apps' : '/datasets'
useEffect(() => {
if (shouldRedirect)
router.replace('/datasets')
}, [shouldRedirect, router])
router.replace(redirectPath)
}, [redirectPath, shouldRedirect, router])
// Block rendering only for guarded routes to avoid permission flicker.
if (shouldGuardRoute && isLoadingCurrentWorkspace)

View File

@ -44,6 +44,10 @@ vi.mock('@/app/components/header/tools-nav', () => ({
default: createMockComponent('tools-nav'),
}))
vi.mock('@/features/deployments/nav', () => ({
DeploymentsNav: createMockComponent('deployments-nav'),
}))
vi.mock('@/app/components/header/plan-badge', () => ({
PlanBadge: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => (
<button data-testid="plan-badge" onClick={onClick} data-plan={plan} />
@ -66,6 +70,7 @@ let mockPlanType = 'sandbox'
let mockBrandingEnabled = false
let mockBrandingTitle: string | null = null
let mockBrandingLogo: string | null = null
let mockEnableAppDeploy = false
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
@ -103,6 +108,7 @@ const renderHeader = (ui: ReactElement = <Header />) =>
application_title: mockBrandingTitle ?? '',
workspace_logo: mockBrandingLogo ?? '',
},
enable_app_deploy: mockEnableAppDeploy,
},
})
@ -117,6 +123,7 @@ describe('Header', () => {
mockBrandingEnabled = false
mockBrandingTitle = null
mockBrandingLogo = null
mockEnableAppDeploy = false
})
it('should render header with main nav components', () => {
@ -214,6 +221,24 @@ describe('Header', () => {
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
})
it('should hide deployments nav when app deploy is disabled', () => {
mockIsWorkspaceEditor = true
mockEnableAppDeploy = false
renderHeader()
expect(screen.queryByTestId('deployments-nav')).not.toBeInTheDocument()
})
it('should show deployments nav for editors when app deploy is enabled', () => {
mockIsWorkspaceEditor = true
mockEnableAppDeploy = true
renderHeader()
expect(screen.getByTestId('deployments-nav')).toBeInTheDocument()
})
it('should hide dataset nav when neither editor nor dataset operator', () => {
mockIsWorkspaceEditor = false
mockIsDatasetOperator = false

View File

@ -37,6 +37,7 @@ export function Header() {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isFreePlan = plan.type === Plan.sandbox
const isBrandingEnabled = systemFeatures.branding.enabled
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
function handlePlanClick() {
if (isFreePlan)
@ -86,7 +87,7 @@ export function Header() {
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{isCurrentWorkspaceEditor && <DeploymentsNav />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
</div>
)
@ -107,7 +108,7 @@ export function Header() {
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{isCurrentWorkspaceEditor && <DeploymentsNav />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 pr-3 pl-2 min-[1280px]:pl-3">
<EnvNav />

View File

@ -1,26 +1,32 @@
import type { ModelProviderQuotaGetPaid } from './model-provider'
export enum SSOProtocol {
SAML = 'saml',
OIDC = 'oidc',
OAuth2 = 'oauth2',
}
export const SSOProtocol = {
SAML: 'saml',
OIDC: 'oidc',
OAuth2: 'oauth2',
} as const
export enum LicenseStatus {
NONE = 'none',
INACTIVE = 'inactive',
ACTIVE = 'active',
EXPIRING = 'expiring',
EXPIRED = 'expired',
LOST = 'lost',
}
export type SSOProtocol = typeof SSOProtocol[keyof typeof SSOProtocol]
export enum InstallationScope {
ALL = 'all',
NONE = 'none',
OFFICIAL_ONLY = 'official_only',
OFFICIAL_AND_PARTNER = 'official_and_specific_partners',
}
export const LicenseStatus = {
NONE: 'none',
INACTIVE: 'inactive',
ACTIVE: 'active',
EXPIRING: 'expiring',
EXPIRED: 'expired',
LOST: 'lost',
} as const
export type LicenseStatus = typeof LicenseStatus[keyof typeof LicenseStatus]
export const InstallationScope = {
ALL: 'all',
NONE: 'none',
OFFICIAL_ONLY: 'official_only',
OFFICIAL_AND_PARTNER: 'official_and_specific_partners',
} as const
export type InstallationScope = typeof InstallationScope[keyof typeof InstallationScope]
type License = {
status: LicenseStatus
@ -29,6 +35,7 @@ type License = {
export type SystemFeatures = {
app_dsl_version: string
enable_app_deploy: boolean
trial_models: ModelProviderQuotaGetPaid[]
plugin_installation_permission: {
plugin_installation_scope: InstallationScope
@ -71,6 +78,7 @@ export type SystemFeatures = {
export const defaultSystemFeatures: SystemFeatures = {
app_dsl_version: '',
enable_app_deploy: false,
trial_models: [],
plugin_installation_permission: {
plugin_installation_scope: InstallationScope.ALL,