fix: add RBAC feature across various components (#37732)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wu Tianwei 2026-06-22 15:30:28 +08:00 committed by GitHub
parent c83dcce1f7
commit 8f6b57fe24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 1099 additions and 360 deletions

View File

@ -986,6 +986,9 @@
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-noninteractive-element-to-interactive-role": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
@ -1003,6 +1006,11 @@
"count": 3
}
},
"web/app/components/apps/starred-app-card.tsx": {
"jsx-a11y/no-noninteractive-element-to-interactive-role": {
"count": 1
}
},
"web/app/components/base/action-button/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -3364,14 +3372,6 @@
"count": 1
}
},
"web/app/components/datasets/list/dataset-card/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/list/dataset-card/operation-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1

View File

@ -1,6 +1,7 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import DatasetInfo from '@/app/components/app-sidebar/dataset-info'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'

View File

@ -1,5 +1,6 @@
import type { App } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useStore } from '@/app/components/app/store'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
@ -10,6 +11,13 @@ import AppDetailLayout from '../layout-main'
const mockReplace = vi.fn()
let mockPathname = '/app/app-1/workflow'
let mockIsLoadingWorkspacePermissionKeys = false
let mockIsRbacEnabled = true
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
@ -57,6 +65,7 @@ describe('AppDetailLayout', () => {
vi.clearAllMocks()
mockPathname = '/app/app-1/workflow'
mockIsLoadingWorkspacePermissionKeys = false
mockIsRbacEnabled = true
mockUsePathname.mockImplementation(() => mockPathname)
mockUseRouter.mockReturnValue({
back: vi.fn(),
@ -262,6 +271,24 @@ describe('AppDetailLayout', () => {
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should redirect access config pages when RBAC is disabled', async () => {
mockIsRbacEnabled = false
mockPathname = '/app/app-1/access-config'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] }))
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/develop')
})
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
expect(useStore.getState().appDetail).toBeUndefined()
})
it('should redirect annotation pages when edit access is missing', async () => {
mockPathname = '/app/app-1/annotations'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -9,6 +10,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
@ -36,7 +38,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, currentWorkspace, userProfile, workspacePermissionKeys } = useAppContext()
const isRbacEnabled = systemFeatures.rbac_enabled
const { appDetail, setAppDetail } = useStore(useShallow(state => ({
appDetail: state.appDetail,
setAppDetail: state.setAppDetail,
@ -95,6 +99,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
currentUserId: userProfile?.id,
resourceMaintainer: routeAppDetail.maintainer,
workspacePermissionKeys,
isRbacEnabled,
})
const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow')
const isLogsPath = pathname.endsWith('logs')
@ -112,6 +117,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
currentUserId: userProfile?.id,
resourceMaintainer: routeAppDetail.maintainer,
workspacePermissionKeys,
isRbacEnabled,
}))
return
}
@ -125,7 +131,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
if (appDetailRes && appDetail?.id !== appDetailRes.id)
setAppDetail({ ...appDetailRes, enable_sso: false })
}, [appDetail?.id, appDetailRes, appId, currentWorkspace.id, isLoadingAppDetail, isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, pathname, routeAppDetail, router, setAppDetail, userProfile?.id, workspacePermissionKeys])
}, [appDetail?.id, appDetailRes, appId, currentWorkspace.id, isLoadingAppDetail, isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, isRbacEnabled, pathname, routeAppDetail, router, setAppDetail, userProfile?.id, workspacePermissionKeys])
if (!appDetail) {
return (

View File

@ -1,10 +1,18 @@
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
import { DatasetACLPermission } from '@/utils/permission'
import DatasetDetailLayout from '../layout-main'
const mockReplace = vi.fn()
let mockIsRbacEnabled = true
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
@ -42,6 +50,7 @@ const mockUseDatasetDetail = vi.mocked(useDatasetDetail)
describe('DatasetDetailLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsRbacEnabled = true
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents')
mockUseRouter.mockReturnValue({
back: vi.fn(),
@ -292,5 +301,36 @@ describe('DatasetDetailLayout', () => {
expect(screen.getByText('Access config content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should redirect from access config when RBAC is disabled', async () => {
// Arrange
mockIsRbacEnabled = false
mockUsePathname.mockReturnValue('/datasets/dataset-1/access-config')
mockUseDatasetDetail.mockReturnValue({
data: {
id: 'dataset-1',
name: 'Dataset 1',
provider: 'vendor',
runtime_mode: 'general',
is_published: true,
permission_keys: [DatasetACLPermission.AccessConfig],
},
error: null,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDatasetDetail>)
// Act
render(
<DatasetDetailLayout datasetId="dataset-1">
<div>Access config content</div>
</DatasetDetailLayout>,
)
// Assert
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/datasets/dataset-1/documents')
})
expect(screen.queryByText('Access config content')).not.toBeInTheDocument()
})
})
})

View File

@ -2,12 +2,14 @@
import type { FC } from 'react'
import type { DataSet } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import DatasetDetailContext from '@/context/dataset-detail'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
@ -56,12 +58,14 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const {
isLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys,
userProfile,
workspacePermissionKeys,
} = useAppContext()
const isRbacEnabled = systemFeatures.rbac_enabled
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
const shouldRedirect = shouldRedirectToDatasetList(error)
@ -69,7 +73,8 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
currentUserId: userProfile?.id,
resourceMaintainer: datasetRes?.maintainer,
workspacePermissionKeys,
}), [datasetRes?.maintainer, datasetRes?.permission_keys, userProfile?.id, workspacePermissionKeys])
isRbacEnabled,
}), [datasetRes?.maintainer, datasetRes?.permission_keys, isRbacEnabled, userProfile?.id, workspacePermissionKeys])
const isAccessConfigPath = pathname.endsWith('/access-config')
const isHitTestingPath = pathname.endsWith('/hitTesting')
const isPermissionControlledPath = isAccessConfigPath || isHitTestingPath

View File

@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { AppACLPermission } from '@/utils/permission'
import AppDetailSection from '../app-detail-section'
import { useAppInfoActions } from '../app-info/use-app-info-actions'
@ -6,6 +7,13 @@ import { useAppInfoActions } from '../app-info/use-app-info-actions'
let mockAppMode = 'chat'
let mockPathname = '/app/app-1/logs'
let mockAppPermissionKeys: string[] = []
let mockIsRbacEnabled = true
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
@ -56,6 +64,7 @@ describe('AppDetailSection', () => {
mockAppMode = 'chat'
mockPathname = '/app/app-1/logs'
mockAppPermissionKeys = [AppACLPermission.Monitor]
mockIsRbacEnabled = true
})
// Rendering behavior for app detail navigation entries.
@ -203,6 +212,18 @@ describe('AppDetailSection', () => {
expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
})
it('should hide resource access navigation when RBAC is disabled', () => {
// Arrange
mockIsRbacEnabled = false
mockAppPermissionKeys = [AppACLPermission.AccessConfig]
// Act
render(<AppDetailSection />)
// Assert
expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
})
it('should pass collapsed mode to app info and navigation links when collapsed', () => {
// Act
render(<AppDetailSection expand={false} />)

View File

@ -1,11 +1,19 @@
import type { DataSet, RelatedAppResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { DatasetACLPermission } from '@/utils/permission'
import DatasetDetailSection from '../dataset-detail-section'
let mockPathname = '/datasets/dataset-1/documents'
let mockDataset: DataSet | undefined
let mockRelatedApps: RelatedAppResponse | undefined
let mockIsRbacEnabled = true
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
@ -77,6 +85,7 @@ describe('DatasetDetailSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/datasets/dataset-1/documents'
mockIsRbacEnabled = true
mockDataset = createDataset()
mockRelatedApps = {
data: [],
@ -120,6 +129,17 @@ describe('DatasetDetailSection', () => {
expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
})
it('should hide resource access navigation when RBAC is disabled', () => {
mockIsRbacEnabled = false
mockDataset = createDataset({
permission_keys: [DatasetACLPermission.AccessConfig],
})
render(<DatasetDetailSection expand />)
expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
})
it('should render hit testing navigation as a link when retrieval recall permission is granted', () => {
mockDataset = createDataset({
permission_keys: [DatasetACLPermission.RetrievalRecall],

View File

@ -15,12 +15,14 @@ import {
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { Fragment, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/app/store'
import Divider from '@/app/components/base/divider'
import Annotations from '@/app/components/base/icons/src/vender/Annotations'
import { useAppContext } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { usePathname } from '@/next/navigation'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities } from '@/utils/permission'
@ -71,7 +73,9 @@ const AppDetailSection = ({
}: AppDetailSectionProps) => {
const { t } = useTranslation()
const pathname = usePathname()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { userProfile, workspacePermissionKeys } = useAppContext()
const isRbacEnabled = systemFeatures.rbac_enabled
const appDetail = useStore(state => state.appDetail)
const appInfoActions = useAppInfoActions({
resetKey: appDetail?.id,
@ -88,6 +92,7 @@ const AppDetailSection = ({
currentUserId: userProfile?.id,
resourceMaintainer: appDetail.maintainer,
workspacePermissionKeys,
isRbacEnabled,
})
return [
@ -143,7 +148,7 @@ const AppDetailSection = ({
: []
),
]
}, [appDetail, t, userProfile?.id, workspacePermissionKeys])
}, [appDetail, t, userProfile?.id, workspacePermissionKeys, isRbacEnabled])
if (!appDetail)
return null

View File

@ -69,6 +69,10 @@ vi.mock('@/service/use-apps', () => ({
}))
vi.mock('@tanstack/react-query', () => ({
queryOptions: <TOptions>(options: TOptions) => options,
useSuspenseQuery: () => ({
data: { rbac_enabled: true },
}),
useQueryClient: () => ({
setQueryData: mockSetQueryData,
}),

View File

@ -3,12 +3,13 @@ import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-moda
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
import { appDetailQueryKeyPrefix, useInvalidateAppList } from '@/service/use-apps'
@ -58,6 +59,8 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const invalidateAppList = useInvalidateAppList()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const [uiState, setUiState] = useState(() => createInitialUiState(resetKey))
const uiStateMatchesResetKey = uiState.resetKey === resetKey
@ -216,12 +219,12 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction
toast(t('newApp.appCreated', { ns: 'app' }), { type: 'success' })
setNeedRefresh('1')
onPlanInfoChanged()
getRedirection(newApp, replace)
getRedirection(newApp, replace, { isRbacEnabled })
}
catch {
toast(t('newApp.appCreateFailed', { ns: 'app' }), { type: 'error' })
}
}, [appDetail, closeModal, onPlanInfoChanged, replace, setNeedRefresh, t])
}, [appDetail, closeModal, isRbacEnabled, onPlanInfoChanged, replace, setNeedRefresh, t])
const onExport = useCallback(async (include = false) => {
if (!appDetail)

View File

@ -12,6 +12,7 @@ import {
RiLock2Fill,
RiLock2Line,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
@ -19,6 +20,7 @@ import { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vend
import ExtraInfo from '@/app/components/datasets/extra-info'
import { useAppContext } from '@/context/app-context'
import DatasetDetailContext from '@/context/dataset-detail'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { usePathname } from '@/next/navigation'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import { getDatasetACLCapabilities } from '@/utils/permission'
@ -40,14 +42,17 @@ const DatasetDetailSection = ({
const { t } = useTranslation()
const pathname = usePathname()
const datasetId = getDatasetIdFromPathname(pathname)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { userProfile, workspacePermissionKeys } = useAppContext()
const isRbacEnabled = systemFeatures.rbac_enabled
const { data: datasetRes, refetch: mutateDatasetRes } = useDatasetDetail(datasetId ?? '')
const { data: relatedApps } = useDatasetRelatedApps(datasetId ?? '', { enabled: !!datasetId && !!datasetRes })
const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(datasetRes?.permission_keys, {
currentUserId: userProfile?.id,
resourceMaintainer: datasetRes?.maintainer,
workspacePermissionKeys,
}), [datasetRes?.maintainer, datasetRes?.permission_keys, userProfile?.id, workspacePermissionKeys])
isRbacEnabled,
}), [datasetRes?.maintainer, datasetRes?.permission_keys, isRbacEnabled, userProfile?.id, workspacePermissionKeys])
const isButtonDisabledWithPipeline = useMemo(() => {
if (!datasetRes)

View File

@ -1,7 +1,8 @@
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
ChunkingMode,
DatasetPermission,
@ -19,6 +20,13 @@ const mockExportPipeline = vi.fn()
const mockCheckIsUsedInApp = vi.fn()
const mockDeleteDataset = vi.fn()
const mockToast = vi.fn()
let mockIsRbacEnabled = true
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
@ -94,6 +102,13 @@ vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }),
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [],
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
datasetDetailQueryKeyPrefix: ['dataset', 'detail'],
useInvalidDatasetList: () => mockInvalidDatasetList,
@ -142,6 +157,7 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({
describe('Dropdown callback coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsRbacEnabled = true
mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' })
mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' })
mockCheckIsUsedInApp.mockResolvedValue({ is_using: false })

View File

@ -1,7 +1,8 @@
import type { DataSet } from '@/models/datasets'
import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createEvent, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
ChunkingMode,
DatasetPermission,
@ -23,6 +24,13 @@ const mockExportPipeline = vi.fn()
const mockCheckIsUsedInApp = vi.fn()
const mockDeleteDataset = vi.fn()
const TestEditIcon = () => <span aria-hidden className="i-ri-edit-line" />
let mockIsRbacEnabled = true
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
@ -107,6 +115,13 @@ vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }),
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [],
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
datasetDetailQueryKeyPrefix: ['dataset', 'detail'],
useInvalidDatasetList: () => mockInvalidDatasetList,
@ -162,6 +177,7 @@ const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
describe('DatasetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsRbacEnabled = true
mockDataset = createDataset()
})
@ -374,6 +390,7 @@ describe('Dropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' })
mockIsRbacEnabled = true
mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' })
mockCheckIsUsedInApp.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
@ -430,6 +447,24 @@ describe('Dropdown', () => {
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should hide resource access option when RBAC is disabled', async () => {
const user = userEvent.setup()
// Arrange
mockIsRbacEnabled = false
mockDataset = createDataset({
runtime_mode: 'general',
permission_keys: [DatasetACLPermission.AccessConfig, DatasetACLPermission.Delete],
})
render(<Dropdown expand />)
// Act
await openMenu(user)
// Assert
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
expect(screen.queryByText('common.settings.resourceAccess')).not.toBeInTheDocument()
})
})
// User interactions that trigger modals and exports.

View File

@ -15,11 +15,13 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
@ -69,11 +71,14 @@ const DropDown = ({
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, {
currentUserId,
resourceMaintainer: dataset?.maintainer,
workspacePermissionKeys,
}), [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys])
isRbacEnabled,
}), [dataset?.maintainer, dataset?.permission_keys, currentUserId, isRbacEnabled, workspacePermissionKeys])
const canShowOperations = datasetACLCapabilities.canEdit
|| datasetACLCapabilities.canImportExportDSL
|| datasetACLCapabilities.canAccessConfig

View File

@ -1,5 +1,6 @@
import type { AccessRulesEditorProps } from '@/app/components/access-rules-editor'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useStore } from '@/app/components/app/store'
import {
useAppAccessRules,
@ -13,6 +14,14 @@ const mockAppContext = vi.hoisted(() => ({
workspacePermissionKeys: [] as string[],
}))
let mockIsRbacEnabled = true
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
const mockAppAccessRules = vi.hoisted(() => ({
items: [] as AccessRulesEditorProps['rules'],
isLoading: false,
@ -75,6 +84,7 @@ describe('AppAccessConfigPage', () => {
vi.clearAllMocks()
mockAppContext.userProfile = { id: 'user-1' }
mockAppContext.workspacePermissionKeys = []
mockIsRbacEnabled = true
mockAppAccessRules.items = []
mockAppAccessRules.isLoading = false
mockAppUserAccessSettings.data = []
@ -195,6 +205,16 @@ describe('AppAccessConfigPage', () => {
expect(useAppUserAccessSettings).not.toHaveBeenCalled()
})
it('should not mount access config data hooks when RBAC is disabled', () => {
mockIsRbacEnabled = false
render(<AppAccessConfigPage appId="app-1" />)
expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument()
expect(useAppAccessRules).not.toHaveBeenCalled()
expect(useAppUserAccessSettings).not.toHaveBeenCalled()
})
it('should allow the app maintainer with app management workspace permission', () => {
mockAppContext.userProfile = { id: 'account-1' }
mockAppContext.workspacePermissionKeys = ['app.create_and_management']

View File

@ -2,12 +2,14 @@
import type { ResourceOpenScope } from '@/models/access-control'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccessRulesEditor from '@/app/components/access-rules-editor'
import { useStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { getAccessControlTemplateLanguage } from '@/i18n-config/language'
import {
useAppAccessRules,
@ -103,13 +105,16 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP
}
const AppAccessConfigPage = ({ appId }: AppAccessConfigPageProps) => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { userProfile, workspacePermissionKeys } = useAppContext()
const isRbacEnabled = systemFeatures.rbac_enabled
const appDetail = useStore(state => state.appDetail)
const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, {
currentUserId: userProfile?.id,
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
}), [appDetail?.maintainer, appDetail?.permission_keys, userProfile?.id, workspacePermissionKeys])
isRbacEnabled,
}), [appDetail?.maintainer, appDetail?.permission_keys, isRbacEnabled, userProfile?.id, workspacePermissionKeys])
if (!appDetail || appDetail.id !== appId || !appACLCapabilities.canAccessConfig)
return null

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
import { AppModeEnum } from '@/types/app'
import Apps from '../index'
@ -295,7 +296,7 @@ describe('Apps', () => {
id: 'created-app-id',
mode: AppModeEnum.CHAT,
permission_keys: ['app.acl.view_layout'],
}, mockPush)
}, mockPush, { isRbacEnabled: false })
})
it('shows an error toast when importing the template fails', async () => {

View File

@ -5,6 +5,7 @@ import type { App } from '@/models/explore'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiRobot2Line } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useMemo, useState } from 'react'
@ -17,6 +18,7 @@ import Loading from '@/app/components/base/loading'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { useAppContext } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { DSLImportMode } from '@/models/app'
import { useRouter } from '@/next/navigation'
import { importDSL } from '@/service/apps'
@ -45,7 +47,9 @@ const Apps = ({
onCreateFromBlank,
}: AppsProps) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { workspacePermissionKeys } = useAppContext()
const isRbacEnabled = systemFeatures.rbac_enabled
const canCreateAppFromTemplate = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const { push } = useRouter()
const invalidateAppList = useInvalidateAppList()
@ -144,7 +148,7 @@ const Apps = ({
setNeedRefresh('1')
invalidateAppList()
if (app.app_id)
getRedirection({ id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys }, push)
getRedirection({ id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys }, push, { isRbacEnabled })
}
catch {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))

View File

@ -1,7 +1,8 @@
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
@ -177,6 +178,7 @@ describe('CreateAppModal', () => {
currentUserId: 'user-1',
resourceMaintainer: 'user-1',
workspacePermissionKeys: ['app.create_and_management'],
isRbacEnabled: false,
}),
)
})

View File

@ -9,6 +9,7 @@ import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -20,6 +21,7 @@ import Input from '@/app/components/base/input'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useTheme from '@/hooks/use-theme'
import { useRouter } from '@/next/navigation'
import { createApp } from '@/service/apps'
@ -56,7 +58,9 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
const { plan, enableBilling } = useProviderContext()
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { userProfile, workspacePermissionKeys } = useAppContext()
const isRbacEnabled = systemFeatures.rbac_enabled
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const invalidateAppList = useInvalidateAppList()
@ -100,13 +104,14 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
currentUserId: userProfile?.id,
resourceMaintainer: app.maintainer,
workspacePermissionKeys,
isRbacEnabled,
})
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('newApp.appCreateFailed', { ns: 'app' }))
}
isCreatingRef.current = false
}, [canCreateApp, name, t, appMode, appIcon, description, onSuccess, onClose, push, userProfile?.id, workspacePermissionKeys, setNeedRefresh, invalidateAppList])
}, [canCreateApp, name, t, appMode, appIcon, description, onSuccess, onClose, push, userProfile?.id, workspacePermissionKeys, isRbacEnabled, setNeedRefresh, invalidateAppList])
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useHotkey('Mod+Enter', () => {

View File

@ -2,10 +2,10 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
import { DSLImportMode, DSLImportStatus } from '@/models/app'
import { AppModeEnum } from '@/types/app'
@ -247,6 +247,7 @@ describe('CreateFromDSLModal', () => {
expect(mockGetRedirection).toHaveBeenCalledWith(
{ id: 'app-1', mode: 'chat', permission_keys: ['app.acl.view_layout'] },
mockPush,
{ isRbacEnabled: false },
)
})

View File

@ -7,6 +7,7 @@ import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { toast } from '@langgenius/dify-ui/toast'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -15,6 +16,7 @@ import Input from '@/app/components/base/input'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import {
DSLImportMode,
DSLImportStatus,
@ -55,6 +57,8 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
const [importId, setImportId] = useState<string>()
const { handleCheckPluginDependencies } = usePluginDependencies()
const setNeedRefresh = useSetNeedRefreshAppList()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const readFile = useCallback((file: File) => {
const reader = new FileReader()
@ -129,7 +133,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
invalidateAppList()
if (app_id) {
await handleCheckPluginDependencies(app_id)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled })
}
}
else if (status === DSLImportStatus.PENDING) {
@ -184,7 +188,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
setNeedRefresh('1')
invalidateAppList()
if (app_id)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled })
}
else if (status === DSLImportStatus.FAILED) {
toast.error(response.error || t('newApp.appCreateFailed', { ns: 'app' }))

View File

@ -1,5 +1,6 @@
import type { App } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { AppModeEnum } from '@/types/app'
import EmptyElement from '../empty-element'

View File

@ -1,9 +1,11 @@
'use client'
import type { FC, SVGProps } from 'react'
import type { App } from '@/types/app'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { AppModeEnum } from '@/types/app'
import { getRedirectionPath } from '@/utils/app-redirection'
@ -21,6 +23,8 @@ const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => {
const { t } = useTranslation()
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const getWebAppType = (appType: AppModeEnum) => {
if (appType !== AppModeEnum.COMPLETION && appType !== AppModeEnum.WORKFLOW)
@ -54,6 +58,7 @@ const EmptyElement: FC<{ appDetail: App }> = ({ appDetail }) => {
currentUserId,
resourceMaintainer: appDetail.maintainer,
workspacePermissionKeys,
isRbacEnabled,
})}
className="text-util-colors-blue-blue-600"
/>

View File

@ -1,7 +1,8 @@
import type { App } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { useStore as useAppStore } from '@/app/components/app/store'
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
import { Plan } from '@/app/components/billing/type'

View File

@ -16,6 +16,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
@ -25,6 +26,7 @@ import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/aler
import Input from '@/app/components/base/input'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import { deleteApp, switchApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
@ -43,6 +45,8 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
const { push, replace } = useRouter()
const { t } = useTranslation()
const setAppDetail = useAppStore(s => s.setAppDetail)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const { plan, enableBilling } = useProviderContext()
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
@ -86,6 +90,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
permission_keys,
},
removeOriginal ? replace : push,
{ isRbacEnabled },
)
}
catch {

View File

@ -19,9 +19,9 @@ import type { MockedFunction } from 'vitest'
import type { ILogsProps } from '../index'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
import type { App, AppIconType, AppModeEnum } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import * as useLogModule from '@/service/use-log'
@ -101,21 +101,8 @@ const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction<typ
// Test Utilities
// ============================================================================
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
return renderWithSystemFeatures(ui)
}
// ============================================================================

View File

@ -13,11 +13,13 @@ import { AppCard } from '../app-card'
import { StarredAppCard } from '../starred-app-card'
let mockWebappAuthEnabled = false
let mockRbacEnabled = true
const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: {
webapp_auth: { enabled: mockWebappAuthEnabled },
branding: { enabled: false },
rbac_enabled: mockRbacEnabled,
},
})
@ -377,6 +379,7 @@ describe('AppCard', () => {
vi.clearAllMocks()
mockOpenAsyncWindow.mockReset()
mockWebappAuthEnabled = false
mockRbacEnabled = true
mockDeleteMutationPending = false
mockToggleStarMutationPending = false
mockAppContext.isCurrentWorkspaceEditor = true
@ -390,6 +393,70 @@ describe('AppCard', () => {
expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument()
})
it('should render preview-only app card as a dimmed information-only card', () => {
const previewOnlyApp = createMockApp({
name: 'Preview Only App',
description: 'Only visible metadata',
author_name: 'Readonly Author',
created_by: 'another-user',
maintainer: 'another-user',
tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'app' as const, binding_count: 0 }],
permission_keys: [AppACLPermission.Preview],
})
render(<AppCard app={previewOnlyApp} />)
const card = screen.getByRole('button', { name: 'Preview Only App' })
expect(card).toHaveClass('opacity-60')
expect(card).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByText('Only visible metadata')).toBeInTheDocument()
expect(screen.getByText('Readonly Author')).toBeInTheDocument()
const tagSelector = screen.getByLabelText('tag-selector')
expect(tagSelector).toBeInTheDocument()
expect(tagSelector).toHaveAttribute('data-can-bind-or-unbind-tags', 'false')
expect(screen.queryByRole('link', { name: 'Preview Only App' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument()
fireEvent.click(tagSelector)
expect(toastMocks.record).not.toHaveBeenCalled()
fireEvent.click(card)
expect(toastMocks.record).toHaveBeenCalledWith({
type: 'warning',
message: 'app.noAccessResourcePermission',
})
})
it('should render preview-only starred app card as a dimmed information-only card', () => {
const previewOnlyApp = createMockApp({
name: 'Preview Only Starred App',
author_name: 'Readonly Author',
created_by: 'another-user',
maintainer: 'another-user',
permission_keys: [AppACLPermission.Preview],
})
render(<StarredAppCard app={previewOnlyApp} />)
const card = screen.getByRole('button', { name: 'Preview Only Starred App' })
expect(card).toHaveClass('opacity-60')
expect(card).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByText('Readonly Author')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Preview Only Starred App' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument()
fireEvent.click(card)
expect(toastMocks.record).toHaveBeenCalledWith({
type: 'warning',
message: 'app.noAccessResourcePermission',
})
})
it('should display app name', () => {
render(<AppCard app={mockApp} />)
expect(screen.getByText('Test App')).toBeInTheDocument()
@ -473,7 +540,22 @@ describe('AppCard', () => {
expect(screen.getByLabelText('tag-selector')).toHaveAttribute('data-can-bind-or-unbind-tags', 'true')
})
it('should not render tag selector without app edit or workspace tag management permission', () => {
it('should allow workspace app tag management permission to bind tags without app edit permission', () => {
mockAppContext.isCurrentWorkspaceEditor = false
mockAppContext.workspacePermissionKeys = ['app.tag.manage']
mockAppContext.userProfile = { id: 'user-2' }
const tagManageApp = createMockApp({
maintainer: 'user-1',
tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }],
permission_keys: [AppACLPermission.ViewLayout],
})
render(<AppCard app={tagManageApp} />)
expect(screen.getByLabelText('tag-selector')).toHaveAttribute('data-can-bind-or-unbind-tags', 'true')
})
it('should render existing app tags as readonly without app edit or workspace tag management permission', () => {
mockAppContext.isCurrentWorkspaceEditor = false
mockAppContext.workspacePermissionKeys = []
mockAppContext.userProfile = { id: 'user-2' }
@ -485,7 +567,7 @@ describe('AppCard', () => {
render(<AppCard app={readonlyApp} />)
expect(screen.queryByLabelText('tag-selector')).not.toBeInTheDocument()
expect(screen.getByLabelText('tag-selector')).toHaveAttribute('data-can-bind-or-unbind-tags', 'false')
})
it('should render with onRefresh callback', () => {
@ -1767,6 +1849,23 @@ describe('AppCard', () => {
expect(screen.getByText('common.settings.resourceAccess')).toBeInTheDocument()
})
it('should hide resource access option when RBAC is disabled', async () => {
mockRbacEnabled = false
const appWithAccessConfigPermission = createMockApp({
created_by: 'another-user',
maintainer: 'another-user',
permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.Delete],
})
render(<AppCard app={appWithAccessConfigPermission} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
})
expect(screen.queryByText('common.settings.resourceAccess')).not.toBeInTheDocument()
})
it('should navigate to app access config when resource access is clicked', async () => {
const appWithAccessConfigPermission = createMockApp({
created_by: 'another-user',

View File

@ -1,6 +1,6 @@
'use client'
import type { FormEvent, FormEventHandler, MouseEvent } from 'react'
import type { FormEvent, FormEventHandler, KeyboardEvent, MouseEvent } from 'react'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
@ -56,7 +56,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection, getRedirectionPath } from '@/utils/app-redirection'
import { downloadBlob } from '@/utils/download'
import { getAppACLCapabilities, hasPermission } from '@/utils/permission'
import { getAppACLCapabilities, hasOnlyAppPreviewPermission, hasPermission } from '@/utils/permission'
import { formatTime } from '@/utils/time'
import { basePath } from '@/utils/var'
@ -297,6 +297,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
const isRbacEnabled = systemFeatures.rbac_enabled
const { onPlanInfoChanged } = useProviderContext()
const { push } = useRouter()
@ -316,8 +317,10 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
currentUserId,
resourceMaintainer,
workspacePermissionKeys,
}), [currentUserId, resourceMaintainer, workspacePermissionKeys])
isRbacEnabled,
}), [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys])
const appACLCapabilities = useMemo(() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), [app.permission_keys, maintainerPermissionOptions])
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const onConfirmDelete = useCallback(async () => {
@ -441,6 +444,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
currentUserId,
resourceMaintainer: getAppResourceMaintainer(newApp),
workspacePermissionKeys,
isRbacEnabled,
})
}
catch {
@ -526,101 +530,103 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
return (
<>
<div
className={cn(
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={starActionLabel}
disabled={isTogglingStar}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-70"
onClick={handleToggleStar}
>
<StarIcon
aria-hidden
className={cn(
app.is_starred ? 'text-text-warning-secondary' : 'text-text-tertiary',
'size-[18px]',
)}
/>
</button>
)}
/>
<TooltipContent>{starActionLabel}</TooltipContent>
</Tooltip>
{shouldShowOperationsMenu && (
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
{!isPreviewOnly && (
<div
className={cn(
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={starActionLabel}
disabled={isTogglingStar}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-70"
onClick={handleToggleStar}
>
<StarIcon
aria-hidden
className={cn(
app.is_starred ? 'text-text-warning-secondary' : 'text-text-tertiary',
'size-[18px]',
)}
/>
</button>
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowEditOption={shouldShowEditOption}
shouldShowDuplicateOption={shouldShowDuplicateOption}
shouldShowExportOption={shouldShowExportOption}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleOpenAccessConfig}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowEditOption={shouldShowEditOption}
shouldShowDuplicateOption={shouldShowDuplicateOption}
shouldShowExportOption={shouldShowExportOption}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleOpenAccessConfig}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
/>
<TooltipContent>{starActionLabel}</TooltipContent>
</Tooltip>
{shouldShowOperationsMenu && (
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowEditOption={shouldShowEditOption}
shouldShowDuplicateOption={shouldShowDuplicateOption}
shouldShowExportOption={shouldShowExportOption}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleOpenAccessConfig}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowEditOption={shouldShowEditOption}
shouldShowDuplicateOption={shouldShowDuplicateOption}
shouldShowExportOption={shouldShowExportOption}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleOpenAccessConfig}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
{showEditModal && (
<EditAppModal
isEditModal
@ -724,6 +730,7 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
const isRbacEnabled = systemFeatures.rbac_enabled
const { onPlanInfoChanged } = useProviderContext()
const { push } = useRouter()
@ -743,8 +750,10 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement
currentUserId,
resourceMaintainer,
workspacePermissionKeys,
}), [currentUserId, resourceMaintainer, workspacePermissionKeys])
isRbacEnabled,
}), [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys])
const appACLCapabilities = useMemo(() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), [app.permission_keys, maintainerPermissionOptions])
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const canManageAppTags = hasPermission(workspacePermissionKeys, 'app.tag.manage')
@ -871,6 +880,7 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement
currentUserId,
resourceMaintainer: getAppResourceMaintainer(newApp),
workspacePermissionKeys,
isRbacEnabled,
})
}
catch {
@ -951,7 +961,7 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement
const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig
const shouldShowDeleteOption = appACLCapabilities.canDelete
const shouldShowOperationsMenu = shouldShowEditOption || shouldShowDuplicateOption || shouldShowExportOption || shouldShowSwitchOption || shouldShowAccessControlOption || shouldShowAccessConfigOption || shouldShowDeleteOption
const shouldShowAppTags = appACLCapabilities.canEdit || canManageAppTags
const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || appACLCapabilities.canEdit)
const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]'
const editTimeText = useMemo(() => {
@ -995,174 +1005,212 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement
const appNameId = useId()
const appDescriptionId = useId()
const appHref = getRedirectionPath(app, maintainerPermissionOptions)
const appCardClassName = cn(
'inline-flex h-full w-full touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out',
isPreviewOnly
? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid'
: 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)
const starActionLabel = app.is_starred
? t('studio.unstarApp', { ns: 'app' })
: t('studio.starApp', { ns: 'app' })
const showPreviewOnlyAccessWarning = useCallback(() => {
toast.warning(t('noAccessResourcePermission', { ns: 'app' }))
}, [t])
const handlePreviewOnlyCardKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Enter' && event.key !== ' ')
return
event.preventDefault()
showPreviewOnlyAccessWarning()
}, [showPreviewOnlyAccessWarning])
const appCardContent = (
<>
<div className="flex shrink-0 items-center gap-3 pt-4 pr-4 pb-2 pl-4">
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<AppTypeIcon type={app.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm" className="size-3" />
</div>
<div className="flex w-0 grow flex-col gap-1 py-px">
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
<div id={appNameId} className="truncate">{app.name}</div>
</div>
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">{appModeLabel}</div>
</div>
{onlinePresenceUsers.length > 0 && (
<div className="ml-3 flex shrink-0 items-start">
<UserAvatarList users={onlinePresenceUsers} size="xxs" maxVisible={3} className="justify-end" />
</div>
)}
</div>
<div className="shrink-0 px-4 py-1 system-xs-regular text-text-tertiary">
<div
id={appDescriptionId}
className="line-clamp-2 min-h-8"
>
{app.description}
</div>
</div>
<div className="flex h-[26px] shrink-0 items-start px-3" />
<div className="flex min-w-0 shrink-0 items-center pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
<div className="truncate">{app.author_name}</div>
<div className="shrink-0">·</div>
<div className="truncate">{editTimeText}</div>
</div>
</div>
</>
)
return (
<>
<div
className="group relative col-span-1 h-41.5"
>
<Link
href={appHref}
aria-labelledby={appNameId}
aria-describedby={app.description ? appDescriptionId : undefined}
className="inline-flex h-full w-full cursor-pointer touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid"
{isPreviewOnly
? (
<article
role="button"
tabIndex={0}
aria-disabled="true"
aria-labelledby={appNameId}
aria-describedby={app.description ? appDescriptionId : undefined}
className={appCardClassName}
onClick={showPreviewOnlyAccessWarning}
onKeyDown={handlePreviewOnlyCardKeyDown}
>
{appCardContent}
</article>
)
: (
<Link
href={appHref}
aria-labelledby={appNameId}
aria-describedby={app.description ? appDescriptionId : undefined}
className={appCardClassName}
>
{appCardContent}
</Link>
)}
<div
className="absolute top-[104px] right-3 left-3 flex h-[26px] min-w-0 items-start"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex shrink-0 items-center gap-3 pt-4 pr-4 pb-2 pl-4">
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
<AppCardTags
appId={app.id}
tags={app.tags}
canBindOrUnbindTags={canBindOrUnbindTags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onRefresh}
/>
</div>
<AppAccessModeIcon accessMode={app.access_mode} />
{!isPreviewOnly && (
<div
className={cn(
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={starActionLabel}
disabled={isTogglingStar}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-70"
onClick={handleToggleStar}
>
<StarIcon
aria-hidden
className={cn(
app.is_starred ? 'text-text-warning-secondary' : 'text-text-tertiary',
'size-[18px]',
)}
/>
</button>
)}
/>
<AppTypeIcon type={app.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm" className="size-3" />
</div>
<div className="flex w-0 grow flex-col gap-1 py-px">
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
<div id={appNameId} className="truncate">{app.name}</div>
</div>
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">{appModeLabel}</div>
</div>
{onlinePresenceUsers.length > 0 && (
<div className="ml-3 flex shrink-0 items-start">
<UserAvatarList users={onlinePresenceUsers} size="xxs" maxVisible={3} className="justify-end" />
</div>
<TooltipContent>{starActionLabel}</TooltipContent>
</Tooltip>
{shouldShowOperationsMenu && (
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowEditOption={shouldShowEditOption}
shouldShowDuplicateOption={shouldShowDuplicateOption}
shouldShowExportOption={shouldShowExportOption}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleOpenAccessConfig}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowEditOption={shouldShowEditOption}
shouldShowDuplicateOption={shouldShowDuplicateOption}
shouldShowExportOption={shouldShowExportOption}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleOpenAccessConfig}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="shrink-0 px-4 py-1 system-xs-regular text-text-tertiary">
<div
id={appDescriptionId}
className="line-clamp-2 min-h-8"
>
{app.description}
</div>
</div>
<div className="flex h-[26px] shrink-0 items-start px-3" />
<div className="flex min-w-0 shrink-0 items-center pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
<div className="truncate">{app.author_name}</div>
<div className="shrink-0">·</div>
<div className="truncate">{editTimeText}</div>
</div>
</div>
</Link>
{shouldShowAppTags && (
<div
className="absolute top-[104px] right-3 left-3 flex h-[26px] min-w-0 items-start"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<AppCardTags
appId={app.id}
tags={app.tags}
canBindOrUnbindTags={appACLCapabilities.canEdit}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onRefresh}
/>
</div>
)}
<AppAccessModeIcon accessMode={app.access_mode} />
<div
className={cn(
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={starActionLabel}
disabled={isTogglingStar}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-70"
onClick={handleToggleStar}
>
<StarIcon
aria-hidden
className={cn(
app.is_starred ? 'text-text-warning-secondary' : 'text-text-tertiary',
'size-[18px]',
)}
/>
</button>
)}
/>
<TooltipContent>{starActionLabel}</TooltipContent>
</Tooltip>
{shouldShowOperationsMenu && (
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-[18px] w-[18px] text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowEditOption={shouldShowEditOption}
shouldShowDuplicateOption={shouldShowDuplicateOption}
shouldShowExportOption={shouldShowExportOption}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleOpenAccessConfig}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowEditOption={shouldShowEditOption}
shouldShowDuplicateOption={shouldShowDuplicateOption}
shouldShowExportOption={shouldShowExportOption}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleOpenAccessConfig}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{showEditModal && (
<EditAppModal

View File

@ -1,13 +1,19 @@
'use client'
import type { KeyboardEvent } from 'react'
import type { App } from '@/types/app'
import { useMemo } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { getRedirectionPath } from '@/utils/app-redirection'
import { hasOnlyAppPreviewPermission } from '@/utils/permission'
import { formatTime } from '@/utils/time'
import { AppCardActionBar } from './app-card'
@ -20,6 +26,9 @@ export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) {
const { t } = useTranslation()
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
const editTimeText = useMemo(() => {
const timestamp = app.updated_at || app.created_at
@ -36,34 +45,69 @@ export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) {
currentUserId,
resourceMaintainer: app.maintainer,
workspacePermissionKeys,
isRbacEnabled,
})
const cardClassName = cn(
'flex h-[72px] min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg px-4 py-3 shadow-xs outline-hidden transition-shadow duration-200',
isPreviewOnly
? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid'
: 'hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)
const showPreviewOnlyAccessWarning = useCallback(() => {
toast.warning(t('noAccessResourcePermission', { ns: 'app' }))
}, [t])
const handlePreviewOnlyCardKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Enter' && event.key !== ' ')
return
event.preventDefault()
showPreviewOnlyAccessWarning()
}, [showPreviewOnlyAccessWarning])
const cardContent = (
<>
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<AppTypeIcon type={app.mode} wrapperClassName="absolute -right-0.5 -bottom-0.5 h-4 w-4 shadow-sm" className="size-3" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
<div className="truncate system-md-semibold text-text-secondary">{app.name}</div>
<div className="flex min-w-0 items-center gap-1 system-xs-regular text-text-tertiary">
{app.author_name && <span className="shrink-0 truncate">{app.author_name}</span>}
{app.author_name && editTimeText && <span className="shrink-0">·</span>}
{editTimeText && <span className="min-w-0 truncate">{editTimeText}</span>}
</div>
</div>
</>
)
return (
<div className="group relative">
<Link
href={href}
className="flex h-[72px] min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg px-4 py-3 shadow-xs outline-hidden transition-shadow duration-200 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<AppTypeIcon type={app.mode} wrapperClassName="absolute -right-0.5 -bottom-0.5 h-4 w-4 shadow-sm" className="size-3" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
<div className="truncate system-md-semibold text-text-secondary">{app.name}</div>
<div className="flex min-w-0 items-center gap-1 system-xs-regular text-text-tertiary">
{app.author_name && <span className="shrink-0 truncate">{app.author_name}</span>}
{app.author_name && editTimeText && <span className="shrink-0">·</span>}
{editTimeText && <span className="min-w-0 truncate">{editTimeText}</span>}
</div>
</div>
</Link>
<AppCardActionBar app={app} onRefresh={onRefresh} />
{isPreviewOnly
? (
<article
role="button"
tabIndex={0}
aria-disabled="true"
aria-label={app.name}
className={cardClassName}
onClick={showPreviewOnlyAccessWarning}
onKeyDown={handlePreviewOnlyCardKeyDown}
>
{cardContent}
</article>
)
: (
<Link href={href} className={cardClassName}>
{cardContent}
</Link>
)}
{!isPreviewOnly && <AppCardActionBar app={app} onRefresh={onRefresh} />}
</div>
)
}

View File

@ -1,5 +1,6 @@
import type { AccessRulesEditorProps } from '@/app/components/access-rules-editor'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
useDatasetAccessRules,
useDatasetUserAccessSettings,
@ -27,6 +28,14 @@ const mockAppContextState = vi.hoisted(() => ({
workspacePermissionKeys: [] as string[],
}))
let mockIsRbacEnabled = true
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
const mockAccessRulesEditor = vi.hoisted(() => ({
props: null as AccessRulesEditorProps | null,
}))
@ -93,6 +102,7 @@ describe('DatasetAccessConfigPage', () => {
}
mockAppContextState.userProfile = { id: 'user-1' }
mockAppContextState.workspacePermissionKeys = []
mockIsRbacEnabled = true
mockAccessRulesEditor.props = null
})
@ -162,6 +172,16 @@ describe('DatasetAccessConfigPage', () => {
expect(vi.mocked(useDatasetUserAccessSettings)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false })
})
it('should disable access config queries and hide the editor when RBAC is disabled', () => {
mockIsRbacEnabled = false
render(<DatasetAccessConfigPage datasetId="dataset-1" />)
expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument()
expect(vi.mocked(useDatasetAccessRules)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false })
expect(vi.mocked(useDatasetUserAccessSettings)).toHaveBeenCalledWith('dataset-1', expect.any(String), { enabled: false })
})
it('should wire open scope and user policy updates', () => {
render(<DatasetAccessConfigPage datasetId="dataset-1" />)

View File

@ -2,6 +2,7 @@
import type { ResourceOpenScope } from '@/models/access-control'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccessRulesEditor from '@/app/components/access-rules-editor'
@ -9,6 +10,7 @@ import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useLocale } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { getAccessControlTemplateLanguage } from '@/i18n-config/language'
import {
useDatasetAccessRules,
@ -30,11 +32,14 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) =>
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const canAccessConfig = useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, {
currentUserId,
resourceMaintainer: dataset?.maintainer,
workspacePermissionKeys,
}).canAccessConfig, [currentUserId, dataset?.maintainer, dataset?.permission_keys, workspacePermissionKeys])
isRbacEnabled,
}).canAccessConfig, [currentUserId, dataset?.maintainer, dataset?.permission_keys, isRbacEnabled, workspacePermissionKeys])
const { data: datasetAccessRulesResponse, isLoading: isLoadingDatasetAccessRules } = useDatasetAccessRules(datasetId, language, { enabled: canAccessConfig })
const { data: datasetUserAccessSettingsResponse, isLoading: isLoadingDatasetUserAccessSettings } = useDatasetUserAccessSettings(datasetId, language, { enabled: canAccessConfig })
const { mutate: updateDatasetOpenScope, isPending: isUpdatingDatasetOpenScope } = useUpdateDatasetOpenScope(datasetId)

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { DatasetACLPermission } from '@/utils/permission'
import DatasetCardFooter from '../components/dataset-card-footer'
import Description from '../components/description'
import DatasetCard from '../index'
@ -21,6 +22,23 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
const mockPush = vi.fn()
const mockOpenAccessConfig = vi.fn()
const mockCloseAccessConfig = vi.fn()
const toastMocks = vi.hoisted(() => {
const record = vi.fn()
const api = Object.assign(vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })), {
success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })),
error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })),
warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })),
info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
})
return { record, api }
})
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMocks.api,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
@ -263,6 +281,11 @@ describe('DatasetCard Integration', () => {
describe('DatasetCard Component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppContextState = {
isCurrentWorkspaceDatasetOperator: false,
userProfile: { id: 'user-1' },
workspacePermissionKeys: [],
}
})
it('should render and navigate to documents when clicked', () => {
@ -273,6 +296,52 @@ describe('DatasetCard Component', () => {
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
})
it('should render preview-only dataset as a dimmed information-only card', () => {
const dataset = createMockDataset({
name: 'Preview Only Dataset',
permission_keys: [DatasetACLPermission.Preview],
tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'knowledge' as const, binding_count: 0 }],
})
render(<DatasetCard dataset={dataset} />)
const card = screen.getByRole('button', { name: 'Preview Only Dataset' })
expect(card).toHaveClass('opacity-60')
expect(card).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByText('Preview Only Dataset')).toBeInTheDocument()
const tagArea = screen.getByTestId('tag-area')
expect(tagArea).toHaveAttribute('data-can-bind-or-unbind-tags', 'false')
expect(screen.queryByTestId('operations-dropdown')).not.toBeInTheDocument()
fireEvent.click(tagArea)
expect(mockPush).not.toHaveBeenCalled()
expect(toastMocks.record).not.toHaveBeenCalled()
fireEvent.click(card)
expect(mockPush).not.toHaveBeenCalled()
expect(toastMocks.record).toHaveBeenCalledWith({
type: 'warning',
message: 'app.noAccessResourcePermission',
})
})
it('should not navigate preview-only external dataset to a detail page', () => {
const dataset = createMockDataset({
provider: 'external',
permission_keys: [DatasetACLPermission.Preview],
})
render(<DatasetCard dataset={dataset} />)
fireEvent.click(screen.getByRole('button', { name: 'Test Dataset' }))
expect(mockPush).not.toHaveBeenCalled()
expect(toastMocks.record).toHaveBeenCalledWith({
type: 'warning',
message: 'app.noAccessResourcePermission',
})
})
it('should use the hover background treatment', () => {
const dataset = createMockDataset()
render(<DatasetCard dataset={dataset} />)
@ -338,6 +407,19 @@ describe('DatasetCard Component', () => {
expect(screen.getByTestId('tag-area')).toHaveAttribute('data-can-bind-or-unbind-tags', 'true')
})
it('should allow tag binding with workspace dataset tag management permission', () => {
mockAppContextState = {
isCurrentWorkspaceDatasetOperator: false,
userProfile: { id: 'user-1' },
workspacePermissionKeys: ['dataset.tag.manage'],
}
const dataset = createMockDataset({ permission_keys: ['dataset.acl.readonly'] })
render(<DatasetCard dataset={dataset} />)
expect(screen.getByTestId('tag-area')).toHaveAttribute('data-can-bind-or-unbind-tags', 'true')
})
it('should not allow tag binding when dataset lacks edit ACL', () => {
const dataset = createMockDataset({ permission_keys: ['dataset.acl.readonly'] })

View File

@ -1,11 +1,29 @@
import type { DataSet } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { DatasetACLPermission } from '@/utils/permission'
import OperationsDropdown from '../operations-dropdown'
const mockAppContextState = vi.hoisted(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
}))
let mockIsRbacEnabled = true
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
vi.mock('@/context/app-context', () => ({
useSelector: vi.fn((selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState)),
}))
describe('OperationsDropdown', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
@ -45,6 +63,9 @@ describe('OperationsDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppContextState.userProfile = { id: 'user-1' }
mockAppContextState.workspacePermissionKeys = []
mockIsRbacEnabled = true
})
describe('Rendering', () => {
@ -119,6 +140,19 @@ describe('OperationsDropdown', () => {
expect(screen.getByText('common.settings.resourceAccess')).toBeInTheDocument()
})
it('should hide resource access option when RBAC is disabled', () => {
mockIsRbacEnabled = false
const dataset = createMockDataset({
permission_keys: [DatasetACLPermission.AccessConfig, DatasetACLPermission.Delete],
})
render(<OperationsDropdown {...defaultProps} dataset={dataset} />)
fireEvent.click(screen.getByLabelText('Dataset operations'))
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
expect(screen.queryByText('common.settings.resourceAccess')).not.toBeInTheDocument()
})
})
describe('Styles', () => {

View File

@ -5,8 +5,10 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { getDatasetACLCapabilities } from '@/utils/permission'
import Operations from '../operations'
@ -28,11 +30,14 @@ const OperationsDropdown = ({
const [open, setOpen] = React.useState(false)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, {
currentUserId,
resourceMaintainer: dataset.maintainer,
workspacePermissionKeys,
}), [dataset.maintainer, dataset.permission_keys, currentUserId, workspacePermissionKeys])
isRbacEnabled,
}), [dataset.maintainer, dataset.permission_keys, currentUserId, isRbacEnabled, workspacePermissionKeys])
const canShowOperations = datasetACLCapabilities.canEdit
|| datasetACLCapabilities.canImportExportDSL
|| datasetACLCapabilities.canAccessConfig

View File

@ -1,10 +1,14 @@
'use client'
import type { KeyboardEvent, MouseEvent } from 'react'
import type { DataSet } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { DatasetCardTags } from '@/features/tag-management/components/dataset-card-tags'
import { useRouter } from '@/next/navigation'
import { getDatasetACLCapabilities } from '@/utils/permission'
import { getDatasetACLCapabilities, hasOnlyDatasetPreviewPermission, hasPermission } from '@/utils/permission'
import CornerLabels from './components/corner-labels'
import DatasetCardFooter from './components/dataset-card-footer'
import DatasetCardHeader from './components/dataset-card-header'
@ -26,6 +30,7 @@ const DatasetCard = ({
onSuccess,
onOpenTagManagement = () => {},
}: DatasetCardProps) => {
const { t } = useTranslation()
const { push } = useRouter()
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
@ -47,14 +52,26 @@ const DatasetCard = ({
const isPipelineUnpublished = useMemo(() => {
return dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
}, [dataset.runtime_mode, dataset.is_published])
const isPreviewOnly = hasOnlyDatasetPreviewPermission(dataset.permission_keys)
const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, {
currentUserId,
resourceMaintainer: dataset.maintainer,
workspacePermissionKeys,
}), [dataset.maintainer, dataset.permission_keys, currentUserId, workspacePermissionKeys])
const canManageAppTags = hasPermission(workspacePermissionKeys, 'dataset.tag.manage')
const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || datasetACLCapabilities.canEdit)
const handleCardClick = (e: React.MouseEvent) => {
const showPreviewOnlyAccessWarning = () => {
toast.warning(t('noAccessResourcePermission', { ns: 'app' }))
}
const handleCardClick = (e: MouseEvent) => {
e.preventDefault()
if (isPreviewOnly) {
showPreviewOnlyAccessWarning()
return
}
if (isExternalProvider) {
push(datasetACLCapabilities.canRetrievalRecall
? `/datasets/${dataset.id}/hitTesting`
@ -68,17 +85,36 @@ const DatasetCard = ({
}
}
const handleTagAreaClick = (e: React.MouseEvent) => {
const handlePreviewOnlyCardKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!isPreviewOnly || (e.key !== 'Enter' && e.key !== ' '))
return
e.preventDefault()
showPreviewOnlyAccessWarning()
}
const handleTagAreaClick = (e: MouseEvent) => {
e.stopPropagation()
e.preventDefault()
}
const cardClassName = cn(
'group relative col-span-1 flex h-41.5 flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-[background-color,box-shadow] duration-200 ease-in-out',
isPreviewOnly
? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden'
: 'cursor-pointer hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5',
)
return (
<>
<div
className="group relative col-span-1 flex h-41.5 cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-[background-color,box-shadow] duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5"
role={isPreviewOnly ? 'button' : undefined}
tabIndex={isPreviewOnly ? 0 : undefined}
aria-disabled={isPreviewOnly ? 'true' : undefined}
aria-label={isPreviewOnly ? dataset.name : undefined}
className={cardClassName}
data-disable-nprogress={true}
onClick={handleCardClick}
onKeyDown={handlePreviewOnlyCardKeyDown}
>
<CornerLabels dataset={dataset} />
<DatasetCardHeader dataset={dataset} />
@ -90,16 +126,18 @@ const DatasetCard = ({
onClick={handleTagAreaClick}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onSuccess}
canBindOrUnbindTags={datasetACLCapabilities.canEdit}
canBindOrUnbindTags={canBindOrUnbindTags}
/>
<DatasetCardFooter dataset={dataset} />
<OperationsDropdown
dataset={dataset}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
/>
{!isPreviewOnly && (
<OperationsDropdown
dataset={dataset}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
/>
)}
</div>
<DatasetCardModals
dataset={dataset}

View File

@ -1,11 +1,13 @@
'use client'
import type { App } from '@/types/app'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { getRedirectionPath } from '@/utils/app-redirection'
@ -21,11 +23,14 @@ const ContinueWorkItem = ({
const { formatTimeFromNow } = useFormatTimeFromNow()
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const updatedAt = (app.updated_at || app.created_at) * 1000
const href = getRedirectionPath(app, {
currentUserId,
resourceMaintainer: app.maintainer,
workspacePermissionKeys,
isRbacEnabled,
})
return (

View File

@ -183,11 +183,13 @@ describe('AccountSetting', () => {
initialTab?: AccountSettingTab
onCancel?: () => void
onTabChange?: (tab: AccountSettingTab) => void
rbacEnabled?: boolean
}) => {
const {
initialTab = ACCOUNT_SETTING_TAB.MEMBERS,
onCancel = mockOnCancel,
onTabChange = mockOnTabChange,
rbacEnabled = true,
} = props ?? {}
const StatefulAccountSetting = () => {
@ -211,6 +213,7 @@ describe('AccountSetting', () => {
branding: { enabled: false },
enable_marketplace: true,
enable_collaboration_mode: false,
rbac_enabled: rbacEnabled,
},
})
}
@ -404,6 +407,26 @@ describe('AccountSetting', () => {
expect(screen.queryByRole('button', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
})
it('should hide role and resource access entries when RBAC is disabled', () => {
// Act
renderAccountSetting({ rbacEnabled: false })
// Assert
expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.rolesAndPermissions' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument()
})
it('should not render direct role pages when RBAC is disabled', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.ACCESS_RULES, rbacEnabled: false })
// Assert
expect(screen.queryByTestId('access-rules-page')).not.toBeInTheDocument()
expect(screen.queryByTestId('permissions-page')).not.toBeInTheDocument()
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
})
it('should hide billing and custom tabs when disabled', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({

View File

@ -3,6 +3,7 @@ import type { AccountSettingTab } from '@/app/components/header/account-setting/
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BillingPage from '@/app/components/billing/billing-page'
@ -13,6 +14,7 @@ import {
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { BillingPermission, hasPermission } from '@/utils/permission'
import AccessRulesPage from './access-rules-page'
@ -51,12 +53,18 @@ export default function AccountSetting({
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { workspacePermissionKeys } = useAppContext()
const canManageWorkspaceRoles = hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const isRbacEnabled = systemFeatures.rbac_enabled
const canManageWorkspaceRoles = isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const canViewBilling = enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View)
const activeMenu = activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling
? ACCOUNT_SETTING_TAB.LANGUAGE
: activeTab
const activeMenu = (() => {
if (activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
return ACCOUNT_SETTING_TAB.LANGUAGE
if ((activeTab === ACCOUNT_SETTING_TAB.PERMISSIONS || activeTab === ACCOUNT_SETTING_TAB.ACCESS_RULES) && !canManageWorkspaceRoles)
return ACCOUNT_SETTING_TAB.MEMBERS
return activeTab
})()
const scrollContainerRef = useRef<HTMLDivElement>(null)
const settingItems: GroupItem[] = [

View File

@ -15,6 +15,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
@ -25,6 +26,7 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import WorkflowPreview from '@/app/components/workflow/workflow-preview'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import { generateWorkflow } from '@/service/debug'
import { fetchWorkflowDraft } from '@/service/workflow'
@ -97,6 +99,8 @@ const RecoveryDialog = ({ open, onOpenChange, title, description, cancelLabel, c
const WorkflowGeneratorModal: React.FC = () => {
const { t } = useTranslation('workflow')
const router = useRouter()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const isOpen = useWorkflowGeneratorStore(s => s.isOpen)
const mode = useWorkflowGeneratorStore(s => s.mode)
@ -347,7 +351,7 @@ const WorkflowGeneratorModal: React.FC = () => {
})
toast.success(t('workflowGenerator.applied'))
closeGenerator()
router.push(getRedirectionPath({ id: appId, mode: appMode, permission_keys: permissionKeys }))
router.push(getRedirectionPath({ id: appId, mode: appMode, permission_keys: permissionKeys }, { isRbacEnabled }))
}
catch (e: unknown) {
if (e instanceof WorkflowApplyOrphanError) {
@ -364,7 +368,7 @@ const WorkflowGeneratorModal: React.FC = () => {
finally {
setApplyingFalse()
}
}, [current, instruction, mode, router, closeGenerator, t, isApplying, setApplyingTrue, setApplyingFalse])
}, [current, instruction, mode, router, closeGenerator, t, isApplying, isRbacEnabled, setApplyingTrue, setApplyingFalse])
const handleApplyToCurrentConfirmed = useCallback(async () => {
if (!current?.graph || !currentAppId || isApplying)

View File

@ -4,6 +4,7 @@ import type {
} from '@/models/app'
import type { AppIconType } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import {
useCallback,
useRef,
@ -12,6 +13,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { DSLImportStatus } from '@/models/app'
import { useRouter } from '@/next/navigation'
import {
@ -42,6 +44,8 @@ export const useImportDSL = () => {
const { handleCheckPluginDependencies } = usePluginDependencies()
const { push } = useRouter()
const invalidateAppList = useInvalidateAppList()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
const importIdRef = useRef<string>('')
const setNeedRefresh = useSetNeedRefreshAppList()
@ -91,7 +95,7 @@ export const useImportDSL = () => {
setNeedRefresh('1')
invalidateAppList()
await handleCheckPluginDependencies(app_id)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled })
}
else if (status === DSLImportStatus.PENDING) {
setVersions({
@ -113,7 +117,7 @@ export const useImportDSL = () => {
finally {
setIsFetching(false)
}
}, [isFetching, t, handleCheckPluginDependencies, push, setNeedRefresh, invalidateAppList])
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, push, setNeedRefresh, invalidateAppList])
const handleImportDSLConfirm = useCallback(async (
{
@ -142,7 +146,7 @@ export const useImportDSL = () => {
await handleCheckPluginDependencies(app_id)
setNeedRefresh('1')
invalidateAppList()
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push)
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled })
}
else if (status === DSLImportStatus.FAILED) {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
@ -156,7 +160,7 @@ export const useImportDSL = () => {
finally {
setIsFetching(false)
}
}, [isFetching, t, handleCheckPluginDependencies, setNeedRefresh, push, invalidateAppList])
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, setNeedRefresh, push, invalidateAppList])
return {
handleImportDSL,

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "سير العمل",
"newAppFromTemplate.sidebar.Writing": "كتابة",
"noAccessPermission": "لا يوجد إذن للوصول إلى تطبيق الويب",
"noAccessResourcePermission": "لا يوجد إذن للوصول إلى هذا المورد",
"noUserInputNode": "عقدة إدخال المستخدم مفقودة",
"notPublishedYet": "التطبيق لم ينشر بعد",
"openInExplore": "فتح في الاستكشاف",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Arbeitsablauf",
"newAppFromTemplate.sidebar.Writing": "Schrift",
"noAccessPermission": "Keine Berechtigung zum Zugriff auf die Webanwendung",
"noAccessResourcePermission": "Keine Berechtigung zum Zugriff auf diese Ressource",
"noUserInputNode": "Fehlender Benutzereingabeknoten",
"notPublishedYet": "App ist noch nicht veröffentlicht",
"openInExplore": "In Explore öffnen",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Workflow",
"newAppFromTemplate.sidebar.Writing": "Writing",
"noAccessPermission": "No permission to access web app",
"noAccessResourcePermission": "No permission to access this resource",
"noUserInputNode": "Missing user input node",
"notPublishedYet": "App is not published yet",
"openInExplore": "Open in Explore",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Flujo de trabajo",
"newAppFromTemplate.sidebar.Writing": "Escritura",
"noAccessPermission": "No se permite el acceso a la aplicación web",
"noAccessResourcePermission": "No tienes permiso para acceder a este recurso",
"noUserInputNode": "Nodo de entrada de usuario faltante",
"notPublishedYet": "La aplicación aún no está publicada",
"openInExplore": "Abrir en Explorar",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "گردش",
"newAppFromTemplate.sidebar.Writing": "نوشتن",
"noAccessPermission": "دسترسی به برنامه وب مجاز نیست",
"noAccessResourcePermission": "مجوز دسترسی به این منبع را ندارید",
"noUserInputNode": "ورودی کاربر پیدا نشد",
"notPublishedYet": "اپ هنوز منتشر نشده است",
"openInExplore": "باز کردن در کاوش",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Flux de travail",
"newAppFromTemplate.sidebar.Writing": "Écriture",
"noAccessPermission": "Pas de permission d'accéder à l'application web",
"noAccessResourcePermission": "Aucune autorisation pour accéder à cette ressource",
"noUserInputNode": "Nœud d'entrée utilisateur manquant",
"notPublishedYet": "L'application n'est pas encore publiée",
"openInExplore": "Ouvrir dans Explorer",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "कार्यप्रवाह",
"newAppFromTemplate.sidebar.Writing": "कृतियाँ",
"noAccessPermission": "वेब एप्लिकेशन तक पहुँचने की अनुमति नहीं है",
"noAccessResourcePermission": "इस संसाधन तक पहुँचने की अनुमति नहीं है",
"noUserInputNode": "उपयोगकर्ता इनपुट नोड गायब है",
"notPublishedYet": "ऐप अभी प्रकाशित नहीं हुआ है",
"openInExplore": "एक्सप्लोर में खोलें",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Alur Kerja",
"newAppFromTemplate.sidebar.Writing": "Tulisan",
"noAccessPermission": "Tidak ada izin untuk mengakses aplikasi web",
"noAccessResourcePermission": "Tidak ada izin untuk mengakses sumber daya ini",
"noUserInputNode": "Node input pengguna hilang",
"notPublishedYet": "Aplikasi belum diterbitkan",
"openInExplore": "Buka di Jelajahi",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Flusso di lavoro",
"newAppFromTemplate.sidebar.Writing": "Scrittura",
"noAccessPermission": "Nessun permesso per accedere all'app web",
"noAccessResourcePermission": "Nessuna autorizzazione per accedere a questa risorsa",
"noUserInputNode": "Nodo di input utente mancante",
"notPublishedYet": "L'app non è ancora pubblicata",
"openInExplore": "Apri in Esplora",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "ワークフロー",
"newAppFromTemplate.sidebar.Writing": "ライティング",
"noAccessPermission": "Web アプリにアクセス権限がありません",
"noAccessResourcePermission": "このリソースにアクセスする権限がありません",
"noUserInputNode": "ユーザー入力ノードが見つかりません",
"notPublishedYet": "アプリはまだ公開されていません",
"openInExplore": "\"探索\" で開く",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "워크플로",
"newAppFromTemplate.sidebar.Writing": "쓰기",
"noAccessPermission": "웹 앱에 대한 접근 권한이 없습니다.",
"noAccessResourcePermission": "이 리소스에 액세스할 권한이 없습니다",
"noUserInputNode": "사용자 입력 노드가 없습니다",
"notPublishedYet": "앱이 아직 출시되지 않았습니다",
"openInExplore": "Explore 에서 열기",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Workflow",
"newAppFromTemplate.sidebar.Writing": "Writing",
"noAccessPermission": "No permission to access web app",
"noAccessResourcePermission": "Geen toestemming om deze resource te openen",
"noUserInputNode": "Missing user input node",
"notPublishedYet": "App is not published yet",
"openInExplore": "Open in Explore",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Przepływ pracy",
"newAppFromTemplate.sidebar.Writing": "Pismo",
"noAccessPermission": "Brak uprawnień do dostępu do aplikacji internetowej",
"noAccessResourcePermission": "Brak uprawnień dostępu do tego zasobu",
"noUserInputNode": "Brak węzła wejściowego użytkownika",
"notPublishedYet": "Aplikacja nie została jeszcze opublikowana",
"openInExplore": "Otwieranie w obszarze Eksploruj",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Fluxo de trabalho",
"newAppFromTemplate.sidebar.Writing": "Escrita",
"noAccessPermission": "Sem permissão para acessar o aplicativo web",
"noAccessResourcePermission": "Sem permissão para acessar este recurso",
"noUserInputNode": "Nodo de entrada do usuário ausente",
"notPublishedYet": "O aplicativo ainda não foi publicado",
"openInExplore": "Abrir no Explore",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Flux de lucru",
"newAppFromTemplate.sidebar.Writing": "Scriere",
"noAccessPermission": "Nici o permisiune pentru a accesa aplicația web",
"noAccessResourcePermission": "Nu ai permisiunea de a accesa această resursă",
"noUserInputNode": "Lipsă nod de intrare pentru utilizator",
"notPublishedYet": "Aplicația nu este încă publicată",
"openInExplore": "Deschide în Explorează",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Рабочий процесс",
"newAppFromTemplate.sidebar.Writing": "Пишущий",
"noAccessPermission": "Нет разрешения на доступ к веб-приложению",
"noAccessResourcePermission": "Нет разрешения на доступ к этому ресурсу",
"noUserInputNode": "Отсутствует узел ввода пользователя",
"notPublishedYet": "Приложение ещё не опубликовано",
"openInExplore": "Открыть в разделе «Обзор»",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Potek dela",
"newAppFromTemplate.sidebar.Writing": "Pisanje",
"noAccessPermission": "Brez dovoljenja za dostop do spletne aplikacije",
"noAccessResourcePermission": "Ni dovoljenja za dostop do tega vira",
"noUserInputNode": "Manjka vozel uporabniškega vnosa",
"notPublishedYet": "Aplikacija še ni objavljena",
"openInExplore": "Odpri v razišči",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "เวิร์กโฟลว์",
"newAppFromTemplate.sidebar.Writing": "การเขียน",
"noAccessPermission": "ไม่มีสิทธิ์เข้าถึงเว็บแอป",
"noAccessResourcePermission": "ไม่มีสิทธิ์เข้าถึงทรัพยากรนี้",
"noUserInputNode": "ไม่มีโหนดป้อนข้อมูลผู้ใช้",
"notPublishedYet": "แอปยังไม่ได้เผยแพร่",
"openInExplore": "เปิดใน Explore",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "İş Akışı",
"newAppFromTemplate.sidebar.Writing": "Yazı",
"noAccessPermission": "Web uygulamasına erişim izni yok",
"noAccessResourcePermission": "Bu kaynağa erişim izniniz yok",
"noUserInputNode": "Eksik kullanıcı girdi düğümü",
"notPublishedYet": "Uygulama henüz yayımlanmadı",
"openInExplore": "Keşfet'te Aç",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Робочий процес",
"newAppFromTemplate.sidebar.Writing": "Написання",
"noAccessPermission": "Немає дозволу на доступ до веб-додатку",
"noAccessResourcePermission": "Немає дозволу на доступ до цього ресурсу",
"noUserInputNode": "Відсутній вузол введення користувача",
"notPublishedYet": "Додаток ще не опублікований",
"openInExplore": "Відкрити в Огляді",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "Quy trình làm việc",
"newAppFromTemplate.sidebar.Writing": "Văn",
"noAccessPermission": "Không được phép truy cập ứng dụng web",
"noAccessResourcePermission": "Không có quyền truy cập tài nguyên này",
"noUserInputNode": "Thiếu nút nhập liệu của người dùng",
"notPublishedYet": "Ứng dụng chưa được phát hành",
"openInExplore": "Mở trong Khám phá",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "工作流",
"newAppFromTemplate.sidebar.Writing": "写作",
"noAccessPermission": "没有权限访问 web 应用",
"noAccessResourcePermission": "当前无权限访问该资源",
"noUserInputNode": "缺少用户输入节点",
"notPublishedYet": "应用暂未发布",
"openInExplore": "在“探索”中打开",

View File

@ -226,6 +226,7 @@
"newAppFromTemplate.sidebar.Workflow": "工作流",
"newAppFromTemplate.sidebar.Writing": "寫作",
"noAccessPermission": "沒有權限訪問網絡應用程式",
"noAccessResourcePermission": "目前沒有權限訪問此資源",
"noUserInputNode": "缺少使用者輸入節點",
"notPublishedYet": "應用程式尚未發布",
"openInExplore": "在“探索”中打開",

View File

@ -71,7 +71,13 @@ describe('app-redirection', () => {
it('returns access config path when app ACL can only configure access', () => {
const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.AccessConfig] }
expect(getRedirectionPath(app)).toBe('/app/app-123/access-config')
expect(getRedirectionPath(app, { isRbacEnabled: true })).toBe('/app/app-123/access-config')
})
it('returns develop path for access config only apps when RBAC is disabled', () => {
const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.AccessConfig] }
expect(getRedirectionPath(app, { isRbacEnabled: false })).toBe('/app/app-123/develop')
})
it('returns overview path when app ACL can only monitor the app', () => {

View File

@ -9,6 +9,8 @@ import {
getAppACLCapabilities,
getDatasetACLCapabilities,
hasEditPermissionForDataset,
hasOnlyAppPreviewPermission,
hasOnlyDatasetPreviewPermission,
hasPermission,
} from './permission'
@ -132,6 +134,32 @@ describe('permission', () => {
})
})
describe('hasOnlyAppPreviewPermission', () => {
it('should return true when app ACL contains only preview permission', () => {
expect(hasOnlyAppPreviewPermission([AppACLPermission.Preview])).toBe(true)
})
it('should return false when app ACL contains preview permission and another permission', () => {
expect(hasOnlyAppPreviewPermission([
AppACLPermission.Preview,
AppACLPermission.ViewLayout,
])).toBe(false)
})
})
describe('hasOnlyDatasetPreviewPermission', () => {
it('should return true when dataset ACL contains only preview permission', () => {
expect(hasOnlyDatasetPreviewPermission([DatasetACLPermission.Preview])).toBe(true)
})
it('should return false when dataset ACL contains preview permission and another permission', () => {
expect(hasOnlyDatasetPreviewPermission([
DatasetACLPermission.Preview,
DatasetACLPermission.Readonly,
])).toBe(false)
})
})
describe('app maintainer capabilities', () => {
it('grants all app ACL capabilities without injecting app ACL permission keys', () => {
const permissionKeys: string[] = []
@ -139,6 +167,7 @@ describe('permission', () => {
currentUserId: 'user-1',
resourceMaintainer: 'user-1',
workspacePermissionKeys: ['app.create_and_management'],
isRbacEnabled: true,
})
expect(capabilities.canViewLayout).toBe(true)
@ -163,6 +192,14 @@ describe('permission', () => {
expect(capabilities.canEdit).toBe(false)
expect(capabilities.canDelete).toBe(false)
})
it('does not grant app access config when RBAC is disabled', () => {
const capabilities = getAppACLCapabilities([AppACLPermission.AccessConfig], {
isRbacEnabled: false,
})
expect(capabilities.canAccessConfig).toBe(false)
})
})
describe('dataset maintainer capabilities', () => {
@ -172,6 +209,7 @@ describe('permission', () => {
currentUserId: 'user-1',
resourceMaintainer: 'user-1',
workspacePermissionKeys: ['dataset.create_and_management'],
isRbacEnabled: true,
})
expect(capabilities.canReadonly).toBe(true)
@ -199,5 +237,13 @@ describe('permission', () => {
expect(capabilities.canEdit).toBe(false)
expect(capabilities.canDelete).toBe(false)
})
it('does not grant dataset access config when RBAC is disabled', () => {
const capabilities = getDatasetACLCapabilities([DatasetACLPermission.AccessConfig], {
isRbacEnabled: false,
})
expect(capabilities.canAccessConfig).toBe(false)
})
})
})

View File

@ -2,6 +2,7 @@ import type { PermissionKey } from '@/models/access-control'
import { DatasetPermission } from '@/models/datasets'
export const AppACLPermission = {
Preview: 'app.acl.preview',
ViewLayout: 'app.acl.view_layout',
TestAndRun: 'app.acl.test_and_run',
Edit: 'app.acl.edit',
@ -13,6 +14,7 @@ export const AppACLPermission = {
} as const
export const DatasetACLPermission = {
Preview: 'dataset.acl.preview',
Readonly: 'dataset.acl.readonly',
Edit: 'dataset.acl.edit',
ImportExportDSL: 'dataset.acl.import_export_dsl',
@ -36,6 +38,7 @@ export type ResourceMaintainerPermissionOptions = {
currentUserId?: string | null
resourceMaintainer?: string | null
workspacePermissionKeys?: readonly PermissionKey[] | null
isRbacEnabled?: boolean
}
type AppACLCapabilities = {
@ -94,6 +97,14 @@ export const hasPermission = (permissionKeys: readonly PermissionKey[] | null |
return permissionKeys.includes(singlePermissionKey)
}
export const hasOnlyAppPreviewPermission = (permissionKeys: readonly PermissionKey[] | null | undefined) => {
return permissionKeys?.length === 1 && permissionKeys[0] === AppACLPermission.Preview
}
export const hasOnlyDatasetPreviewPermission = (permissionKeys: readonly PermissionKey[] | null | undefined) => {
return permissionKeys?.length === 1 && permissionKeys[0] === DatasetACLPermission.Preview
}
const shouldGrantMaintainerPermissions = (
options: ResourceMaintainerPermissionOptions | undefined,
createPermissionKey: PermissionKey,
@ -131,7 +142,7 @@ export const getAppACLCapabilities = (
canDelete: hasResourcePermission(permissionKeys, AppACLPermission.Delete, hasMaintainerPermissions),
canReleaseAndVersion: hasResourcePermission(permissionKeys, AppACLPermission.ReleaseAndVersion, hasMaintainerPermissions),
canMonitor: hasResourcePermission(permissionKeys, AppACLPermission.Monitor, hasMaintainerPermissions),
canAccessConfig: hasResourcePermission(permissionKeys, AppACLPermission.AccessConfig, hasMaintainerPermissions),
canAccessConfig: Boolean(options?.isRbacEnabled) && hasResourcePermission(permissionKeys, AppACLPermission.AccessConfig, hasMaintainerPermissions),
}
}
@ -152,6 +163,6 @@ export const getDatasetACLCapabilities = (
canDeleteFile: hasResourcePermission(permissionKeys, DatasetACLPermission.DeleteFile, hasMaintainerPermissions),
canPipelineRelease: hasResourcePermission(permissionKeys, DatasetACLPermission.PipelineRelease, hasMaintainerPermissions),
canDelete: hasResourcePermission(permissionKeys, DatasetACLPermission.Delete, hasMaintainerPermissions),
canAccessConfig: hasResourcePermission(permissionKeys, DatasetACLPermission.AccessConfig, hasMaintainerPermissions),
canAccessConfig: Boolean(options?.isRbacEnabled) && hasResourcePermission(permissionKeys, DatasetACLPermission.AccessConfig, hasMaintainerPermissions),
}
}