mirror of
https://github.com/langgenius/dify.git
synced 2026-06-23 04:11:09 +08:00
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:
parent
c83dcce1f7
commit
8f6b57fe24
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'] })
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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[] = [
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "سير العمل",
|
||||
"newAppFromTemplate.sidebar.Writing": "كتابة",
|
||||
"noAccessPermission": "لا يوجد إذن للوصول إلى تطبيق الويب",
|
||||
"noAccessResourcePermission": "لا يوجد إذن للوصول إلى هذا المورد",
|
||||
"noUserInputNode": "عقدة إدخال المستخدم مفقودة",
|
||||
"notPublishedYet": "التطبيق لم ينشر بعد",
|
||||
"openInExplore": "فتح في الاستكشاف",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "گردش",
|
||||
"newAppFromTemplate.sidebar.Writing": "نوشتن",
|
||||
"noAccessPermission": "دسترسی به برنامه وب مجاز نیست",
|
||||
"noAccessResourcePermission": "مجوز دسترسی به این منبع را ندارید",
|
||||
"noUserInputNode": "ورودی کاربر پیدا نشد",
|
||||
"notPublishedYet": "اپ هنوز منتشر نشده است",
|
||||
"openInExplore": "باز کردن در کاوش",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "कार्यप्रवाह",
|
||||
"newAppFromTemplate.sidebar.Writing": "कृतियाँ",
|
||||
"noAccessPermission": "वेब एप्लिकेशन तक पहुँचने की अनुमति नहीं है",
|
||||
"noAccessResourcePermission": "इस संसाधन तक पहुँचने की अनुमति नहीं है",
|
||||
"noUserInputNode": "उपयोगकर्ता इनपुट नोड गायब है",
|
||||
"notPublishedYet": "ऐप अभी प्रकाशित नहीं हुआ है",
|
||||
"openInExplore": "एक्सप्लोर में खोलें",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "ワークフロー",
|
||||
"newAppFromTemplate.sidebar.Writing": "ライティング",
|
||||
"noAccessPermission": "Web アプリにアクセス権限がありません",
|
||||
"noAccessResourcePermission": "このリソースにアクセスする権限がありません",
|
||||
"noUserInputNode": "ユーザー入力ノードが見つかりません",
|
||||
"notPublishedYet": "アプリはまだ公開されていません",
|
||||
"openInExplore": "\"探索\" で開く",
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "워크플로",
|
||||
"newAppFromTemplate.sidebar.Writing": "쓰기",
|
||||
"noAccessPermission": "웹 앱에 대한 접근 권한이 없습니다.",
|
||||
"noAccessResourcePermission": "이 리소스에 액세스할 권한이 없습니다",
|
||||
"noUserInputNode": "사용자 입력 노드가 없습니다",
|
||||
"notPublishedYet": "앱이 아직 출시되지 않았습니다",
|
||||
"openInExplore": "Explore 에서 열기",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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ă",
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "Рабочий процесс",
|
||||
"newAppFromTemplate.sidebar.Writing": "Пишущий",
|
||||
"noAccessPermission": "Нет разрешения на доступ к веб-приложению",
|
||||
"noAccessResourcePermission": "Нет разрешения на доступ к этому ресурсу",
|
||||
"noUserInputNode": "Отсутствует узел ввода пользователя",
|
||||
"notPublishedYet": "Приложение ещё не опубликовано",
|
||||
"openInExplore": "Открыть в разделе «Обзор»",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "เวิร์กโฟลว์",
|
||||
"newAppFromTemplate.sidebar.Writing": "การเขียน",
|
||||
"noAccessPermission": "ไม่มีสิทธิ์เข้าถึงเว็บแอป",
|
||||
"noAccessResourcePermission": "ไม่มีสิทธิ์เข้าถึงทรัพยากรนี้",
|
||||
"noUserInputNode": "ไม่มีโหนดป้อนข้อมูลผู้ใช้",
|
||||
"notPublishedYet": "แอปยังไม่ได้เผยแพร่",
|
||||
"openInExplore": "เปิดใน Explore",
|
||||
|
||||
@ -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ç",
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "Робочий процес",
|
||||
"newAppFromTemplate.sidebar.Writing": "Написання",
|
||||
"noAccessPermission": "Немає дозволу на доступ до веб-додатку",
|
||||
"noAccessResourcePermission": "Немає дозволу на доступ до цього ресурсу",
|
||||
"noUserInputNode": "Відсутній вузол введення користувача",
|
||||
"notPublishedYet": "Додаток ще не опублікований",
|
||||
"openInExplore": "Відкрити в Огляді",
|
||||
|
||||
@ -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á",
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "工作流",
|
||||
"newAppFromTemplate.sidebar.Writing": "写作",
|
||||
"noAccessPermission": "没有权限访问 web 应用",
|
||||
"noAccessResourcePermission": "当前无权限访问该资源",
|
||||
"noUserInputNode": "缺少用户输入节点",
|
||||
"notPublishedYet": "应用暂未发布",
|
||||
"openInExplore": "在“探索”中打开",
|
||||
|
||||
@ -226,6 +226,7 @@
|
||||
"newAppFromTemplate.sidebar.Workflow": "工作流",
|
||||
"newAppFromTemplate.sidebar.Writing": "寫作",
|
||||
"noAccessPermission": "沒有權限訪問網絡應用程式",
|
||||
"noAccessResourcePermission": "目前沒有權限訪問此資源",
|
||||
"noUserInputNode": "缺少使用者輸入節點",
|
||||
"notPublishedYet": "應用程式尚未發布",
|
||||
"openInExplore": "在“探索”中打開",
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user