From 8f6b57fe24149749d4b856a5a6d2949993717dd7 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:30:28 +0800 Subject: [PATCH] fix: add RBAC feature across various components (#37732) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 16 +- .../app-sidebar/dataset-info-flow.test.tsx | 3 +- .../[appId]/__tests__/layout-main.spec.tsx | 29 +- .../(appDetailLayout)/[appId]/layout-main.tsx | 8 +- .../__tests__/layout-main.spec.tsx | 42 +- .../[datasetId]/layout-main.tsx | 7 +- .../__tests__/app-detail-section.spec.tsx | 23 +- .../__tests__/dataset-detail-section.spec.tsx | 22 +- .../app-sidebar/app-detail-section.tsx | 7 +- .../__tests__/use-app-info-actions.spec.ts | 4 + .../app-info/use-app-info-actions.ts | 9 +- .../app-sidebar/dataset-detail-section.tsx | 7 +- .../__tests__/dropdown-callbacks.spec.tsx | 18 +- .../dataset-info/__tests__/index.spec.tsx | 37 +- .../app-sidebar/dataset-info/dropdown.tsx | 7 +- .../access-config/__tests__/index.spec.tsx | 22 +- .../components/app/access-config/index.tsx | 7 +- .../app-list/__tests__/index.spec.tsx | 5 +- .../app/create-app-dialog/app-list/index.tsx | 6 +- .../create-app-modal/__tests__/index.spec.tsx | 4 +- .../components/app/create-app-modal/index.tsx | 7 +- .../__tests__/index.spec.tsx | 3 +- .../app/create-from-dsl-modal/index.tsx | 8 +- .../app/log/__tests__/empty-element.spec.tsx | 3 +- web/app/components/app/log/empty-element.tsx | 5 + .../switch-app-modal/__tests__/index.spec.tsx | 3 +- .../components/app/switch-app-modal/index.tsx | 5 + .../app/workflow-log/__tests__/index.spec.tsx | 19 +- .../apps/__tests__/app-card.spec.tsx | 103 +++- web/app/components/apps/app-card.tsx | 554 ++++++++++-------- web/app/components/apps/starred-app-card.tsx | 94 ++- .../access-config/__tests__/index.spec.tsx | 22 +- .../datasets/access-config/index.tsx | 7 +- .../dataset-card/__tests__/index.spec.tsx | 82 +++ .../__tests__/operations-dropdown.spec.tsx | 36 +- .../components/operations-dropdown.tsx | 7 +- .../datasets/list/dataset-card/index.tsx | 62 +- .../components/explore/continue-work/item.tsx | 5 + .../account-setting/__tests__/index.spec.tsx | 23 + .../header/account-setting/index.tsx | 16 +- .../workflow/workflow-generator/index.tsx | 8 +- web/hooks/use-import-dsl.ts | 12 +- web/i18n/ar-TN/app.json | 1 + web/i18n/de-DE/app.json | 1 + web/i18n/en-US/app.json | 1 + web/i18n/es-ES/app.json | 1 + web/i18n/fa-IR/app.json | 1 + web/i18n/fr-FR/app.json | 1 + web/i18n/hi-IN/app.json | 1 + web/i18n/id-ID/app.json | 1 + web/i18n/it-IT/app.json | 1 + web/i18n/ja-JP/app.json | 1 + web/i18n/ko-KR/app.json | 1 + web/i18n/nl-NL/app.json | 1 + web/i18n/pl-PL/app.json | 1 + web/i18n/pt-BR/app.json | 1 + web/i18n/ro-RO/app.json | 1 + web/i18n/ru-RU/app.json | 1 + web/i18n/sl-SI/app.json | 1 + web/i18n/th-TH/app.json | 1 + web/i18n/tr-TR/app.json | 1 + web/i18n/uk-UA/app.json | 1 + web/i18n/vi-VN/app.json | 1 + web/i18n/zh-Hans/app.json | 1 + web/i18n/zh-Hant/app.json | 1 + web/utils/app-redirection.spec.ts | 8 +- web/utils/permission.spec.ts | 46 ++ web/utils/permission.ts | 15 +- 68 files changed, 1099 insertions(+), 360 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7e4521f51cb..60ed887058a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx index bff6f5a29c5..62761743a10 100644 --- a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx +++ b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx @@ -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' diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index f9742f35e13..ec7aad05b74 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -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[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( + +
App page content
+
, + ) + + 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({ diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index dcbcba4116d..517c8819e91 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -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 = (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 = (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 = (props) => { currentUserId: userProfile?.id, resourceMaintainer: routeAppDetail.maintainer, workspacePermissionKeys, + isRbacEnabled, })) return } @@ -125,7 +131,7 @@ const AppDetailLayout: FC = (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 ( diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index 23a9672a220..d669a675dac 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -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[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) + + // Act + render( + +
Access config content
+
, + ) + + // Assert + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets/dataset-1/documents') + }) + expect(screen.queryByText('Access config content')).not.toBeInTheDocument() + }) }) }) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 3791009061d..a7df61c6c57 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -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 = (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 = (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 diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index 6dc9f5dfb19..8d83f6c7711 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -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[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: Record) => 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() + + // 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() diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx index 25763fb11ae..2b19ee5043e 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx @@ -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[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() + + 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], diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 5df0a4ef573..15d963244cb 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -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 diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index 898b8c55a74..8f417ee7ce4 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -69,6 +69,10 @@ vi.mock('@/service/use-apps', () => ({ })) vi.mock('@tanstack/react-query', () => ({ + queryOptions: (options: TOptions) => options, + useSuspenseQuery: () => ({ + data: { rbac_enabled: true }, + }), useQueryClient: () => ({ setQueryData: mockSetQueryData, }), diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index 89688a4882f..24931418733 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -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) diff --git a/web/app/components/app-sidebar/dataset-detail-section.tsx b/web/app/components/app-sidebar/dataset-detail-section.tsx index f337c6b15aa..4c57f9eb51a 100644 --- a/web/app/components/app-sidebar/dataset-detail-section.tsx +++ b/web/app/components/app-sidebar/dataset-detail-section.tsx @@ -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) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index e1bfb008a71..6155445befb 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -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[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) const createDataset = (overrides: Partial = {}): 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 }) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index 63e05435c99..a12e7348675 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -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 = () => +let mockIsRbacEnabled = true + +const render = (ui: Parameters[0]) => renderWithSystemFeatures(ui, { + systemFeatures: { + rbac_enabled: mockIsRbacEnabled, + }, +}) const createDataset = (overrides: Partial = {}): 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) => { 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() + + // 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. diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 6539e6e1715..51c433d5f0a 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -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 diff --git a/web/app/components/app/access-config/__tests__/index.spec.tsx b/web/app/components/app/access-config/__tests__/index.spec.tsx index 49e8c86825d..63909979344 100644 --- a/web/app/components/app/access-config/__tests__/index.spec.tsx +++ b/web/app/components/app/access-config/__tests__/index.spec.tsx @@ -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[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() + + 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'] diff --git a/web/app/components/app/access-config/index.tsx b/web/app/components/app/access-config/index.tsx index 9704540bbd7..6efc718012c 100644 --- a/web/app/components/app/access-config/index.tsx +++ b/web/app/components/app/access-config/index.tsx @@ -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 diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 72e3ff2c19a..ada0afe17fa 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -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 () => { diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 04e759575b9..c97b8b5fcb5 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -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' })) diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index 47c8fb5c46c..d583dadedda 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -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, }), ) }) diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index ce3f11f24c3..a53f07d5b12 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -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', () => { diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index 45bb635edcb..fb58e964b75 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -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 }, ) }) diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index c44e6f3769c..8414b7c6f4f 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -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() 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' })) diff --git a/web/app/components/app/log/__tests__/empty-element.spec.tsx b/web/app/components/app/log/__tests__/empty-element.spec.tsx index 41f82375f9a..f53225c5554 100644 --- a/web/app/components/app/log/__tests__/empty-element.spec.tsx +++ b/web/app/components/app/log/__tests__/empty-element.spec.tsx @@ -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' diff --git a/web/app/components/app/log/empty-element.tsx b/web/app/components/app/log/empty-element.tsx index 232e913b942..70813290319 100644 --- a/web/app/components/app/log/empty-element.tsx +++ b/web/app/components/app/log/empty-element.tsx @@ -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" /> diff --git a/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx index 8d3684c0d03..eda5f2cc11a 100644 --- a/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx @@ -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' diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index 40eb7252746..694623d3e54 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -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 { diff --git a/web/app/components/app/workflow-log/__tests__/index.spec.tsx b/web/app/components/app/workflow-log/__tests__/index.spec.tsx index af5f7ed85a8..79afa706293 100644 --- a/web/app/components/app/workflow-log/__tests__/index.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/index.spec.tsx @@ -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 new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}) - const renderWithQueryClient = (ui: React.ReactElement) => { - const queryClient = createQueryClient() - return render( - - {ui} - , - ) + return renderWithSystemFeatures(ui) } // ============================================================================ diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 78c47fc865c..f1a2c4e67a9 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -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() + + 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() + + 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() 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() + + 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() - 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() + + 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', diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 7e2f173ee3f..92868958505 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -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 ( <> -
- - - - - )} - /> - {starActionLabel} - - {shouldShowOperationsMenu && ( - - + + + + )} - onClick={(e) => { - e.stopPropagation() - e.preventDefault() - }} - > - {t('operation.more', { ns: 'common' })} - - - - {systemFeatures.webapp_auth.enabled - ? ( - - ) - : ( - - )} - - - )} -
+ /> + {starActionLabel} + + {shouldShowOperationsMenu && ( + + { + e.stopPropagation() + e.preventDefault() + }} + > + {t('operation.more', { ns: 'common' })} + + + + {systemFeatures.webapp_auth.enabled + ? ( + + ) + : ( + + )} + + + )} + + )} {showEditModal && ( 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) => { + if (event.key !== 'Enter' && event.key !== ' ') + return + + event.preventDefault() + showPreviewOnlyAccessWarning() + }, [showPreviewOnlyAccessWarning]) + const appCardContent = ( + <> +
+
+ + +
+
+
+
{app.name}
+
+
{appModeLabel}
+
+ {onlinePresenceUsers.length > 0 && ( +
+ +
+ )} +
+
+
+ {app.description} +
+
+
+
+
+
{app.author_name}
+
·
+
{editTimeText}
+
+
+ + ) return ( <>
- + {appCardContent} + + ) + : ( + + {appCardContent} + + )} +
{ + e.stopPropagation() + e.preventDefault() + }} > -
-
- +
+ + {!isPreviewOnly && ( +
+ + + + + )} /> - -
-
-
-
{app.name}
-
-
{appModeLabel}
-
- {onlinePresenceUsers.length > 0 && ( -
- -
+ {starActionLabel} + + {shouldShowOperationsMenu && ( + + { + e.stopPropagation() + e.preventDefault() + }} + > + {t('operation.more', { ns: 'common' })} + + + + {systemFeatures.webapp_auth.enabled + ? ( + + ) + : ( + + )} + + )}
-
-
- {app.description} -
-
-
-
-
-
{app.author_name}
-
·
-
{editTimeText}
-
-
- - {shouldShowAppTags && ( -
{ - e.stopPropagation() - e.preventDefault() - }} - > - -
)} - -
- - - - - )} - /> - {starActionLabel} - - {shouldShowOperationsMenu && ( - - { - e.stopPropagation() - e.preventDefault() - }} - > - {t('operation.more', { ns: 'common' })} - - - - {systemFeatures.webapp_auth.enabled - ? ( - - ) - : ( - - )} - - - )} -
{showEditModal && ( 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) => { + if (event.key !== 'Enter' && event.key !== ' ') + return + + event.preventDefault() + showPreviewOnlyAccessWarning() + }, [showPreviewOnlyAccessWarning]) + const cardContent = ( + <> +
+ + +
+
+
{app.name}
+
+ {app.author_name && {app.author_name}} + {app.author_name && editTimeText && ·} + {editTimeText && {editTimeText}} +
+
+ + ) return (
- -
- - -
-
-
{app.name}
-
- {app.author_name && {app.author_name}} - {app.author_name && editTimeText && ·} - {editTimeText && {editTimeText}} -
-
- - + {isPreviewOnly + ? ( +
+ {cardContent} +
+ ) + : ( + + {cardContent} + + )} + {!isPreviewOnly && }
) } diff --git a/web/app/components/datasets/access-config/__tests__/index.spec.tsx b/web/app/components/datasets/access-config/__tests__/index.spec.tsx index e93c0ef9433..eeeec0dcf88 100644 --- a/web/app/components/datasets/access-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/access-config/__tests__/index.spec.tsx @@ -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[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() + + 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() diff --git a/web/app/components/datasets/access-config/index.tsx b/web/app/components/datasets/access-config/index.tsx index fd9a851ccf1..b1df8e3eea7 100644 --- a/web/app/components/datasets/access-config/index.tsx +++ b/web/app/components/datasets/access-config/index.tsx @@ -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) diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index 4f85e926ea7..46fe308a720 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -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) => record({ message, ...options })), { + success: vi.fn((message: unknown, options?: Record) => record({ type: 'success', message, ...options })), + error: vi.fn((message: unknown, options?: Record) => record({ type: 'error', message, ...options })), + warning: vi.fn((message: unknown, options?: Record) => record({ type: 'warning', message, ...options })), + info: vi.fn((message: unknown, options?: Record) => 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() + + 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() + + 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() @@ -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() + + 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'] }) diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx index 5a6303d83e9..e15135b4281 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx @@ -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[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 => ({ 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() + + fireEvent.click(screen.getByLabelText('Dataset operations')) + + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() + expect(screen.queryByText('common.settings.resourceAccess')).not.toBeInTheDocument() + }) }) describe('Styles', () => { diff --git a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx index 4032810a07a..4c7ded2651e 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx +++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx @@ -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 diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index aee6b54fd93..0e03449aaf5 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -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) => { + 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 ( <>
@@ -90,16 +126,18 @@ const DatasetCard = ({ onClick={handleTagAreaClick} onOpenTagManagement={onOpenTagManagement} onTagsChange={onSuccess} - canBindOrUnbindTags={datasetACLCapabilities.canEdit} + canBindOrUnbindTags={canBindOrUnbindTags} /> - + {!isPreviewOnly && ( + + )}
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 ( diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index d59199769ed..ec1ac9887d2 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -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({ diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 3d9b0b1a8c9..b04404bde17 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -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(null) const settingItems: GroupItem[] = [ diff --git a/web/app/components/workflow/workflow-generator/index.tsx b/web/app/components/workflow/workflow-generator/index.tsx index fade26f0482..e96fe5306ae 100644 --- a/web/app/components/workflow/workflow-generator/index.tsx +++ b/web/app/components/workflow/workflow-generator/index.tsx @@ -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) diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index 97a1f9e93f2..0f7004048fd 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -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('') 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, diff --git a/web/i18n/ar-TN/app.json b/web/i18n/ar-TN/app.json index 3a295fe102a..a019ffcedad 100644 --- a/web/i18n/ar-TN/app.json +++ b/web/i18n/ar-TN/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "سير العمل", "newAppFromTemplate.sidebar.Writing": "كتابة", "noAccessPermission": "لا يوجد إذن للوصول إلى تطبيق الويب", + "noAccessResourcePermission": "لا يوجد إذن للوصول إلى هذا المورد", "noUserInputNode": "عقدة إدخال المستخدم مفقودة", "notPublishedYet": "التطبيق لم ينشر بعد", "openInExplore": "فتح في الاستكشاف", diff --git a/web/i18n/de-DE/app.json b/web/i18n/de-DE/app.json index 08d7b86f085..011c2e6ca39 100644 --- a/web/i18n/de-DE/app.json +++ b/web/i18n/de-DE/app.json @@ -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", diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index d10dfebaf3a..5ca4edbfa39 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -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", diff --git a/web/i18n/es-ES/app.json b/web/i18n/es-ES/app.json index 2fd93664c4d..e89aea906ee 100644 --- a/web/i18n/es-ES/app.json +++ b/web/i18n/es-ES/app.json @@ -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", diff --git a/web/i18n/fa-IR/app.json b/web/i18n/fa-IR/app.json index c69a52adebe..0263f42ebdb 100644 --- a/web/i18n/fa-IR/app.json +++ b/web/i18n/fa-IR/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "گردش", "newAppFromTemplate.sidebar.Writing": "نوشتن", "noAccessPermission": "دسترسی به برنامه وب مجاز نیست", + "noAccessResourcePermission": "مجوز دسترسی به این منبع را ندارید", "noUserInputNode": "ورودی کاربر پیدا نشد", "notPublishedYet": "اپ هنوز منتشر نشده است", "openInExplore": "باز کردن در کاوش", diff --git a/web/i18n/fr-FR/app.json b/web/i18n/fr-FR/app.json index 58309a1dfbd..24ba5206f31 100644 --- a/web/i18n/fr-FR/app.json +++ b/web/i18n/fr-FR/app.json @@ -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", diff --git a/web/i18n/hi-IN/app.json b/web/i18n/hi-IN/app.json index ea4b2f070d4..faf28b6ced3 100644 --- a/web/i18n/hi-IN/app.json +++ b/web/i18n/hi-IN/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "कार्यप्रवाह", "newAppFromTemplate.sidebar.Writing": "कृतियाँ", "noAccessPermission": "वेब एप्लिकेशन तक पहुँचने की अनुमति नहीं है", + "noAccessResourcePermission": "इस संसाधन तक पहुँचने की अनुमति नहीं है", "noUserInputNode": "उपयोगकर्ता इनपुट नोड गायब है", "notPublishedYet": "ऐप अभी प्रकाशित नहीं हुआ है", "openInExplore": "एक्सप्लोर में खोलें", diff --git a/web/i18n/id-ID/app.json b/web/i18n/id-ID/app.json index f1a27e2f9c1..1c8e21a9207 100644 --- a/web/i18n/id-ID/app.json +++ b/web/i18n/id-ID/app.json @@ -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", diff --git a/web/i18n/it-IT/app.json b/web/i18n/it-IT/app.json index c35f6be9aae..785ed4ca848 100644 --- a/web/i18n/it-IT/app.json +++ b/web/i18n/it-IT/app.json @@ -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", diff --git a/web/i18n/ja-JP/app.json b/web/i18n/ja-JP/app.json index d2f338a84de..600c75509ba 100644 --- a/web/i18n/ja-JP/app.json +++ b/web/i18n/ja-JP/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "ワークフロー", "newAppFromTemplate.sidebar.Writing": "ライティング", "noAccessPermission": "Web アプリにアクセス権限がありません", + "noAccessResourcePermission": "このリソースにアクセスする権限がありません", "noUserInputNode": "ユーザー入力ノードが見つかりません", "notPublishedYet": "アプリはまだ公開されていません", "openInExplore": "\"探索\" で開く", diff --git a/web/i18n/ko-KR/app.json b/web/i18n/ko-KR/app.json index a1995c21b46..c8e294277d7 100644 --- a/web/i18n/ko-KR/app.json +++ b/web/i18n/ko-KR/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "워크플로", "newAppFromTemplate.sidebar.Writing": "쓰기", "noAccessPermission": "웹 앱에 대한 접근 권한이 없습니다.", + "noAccessResourcePermission": "이 리소스에 액세스할 권한이 없습니다", "noUserInputNode": "사용자 입력 노드가 없습니다", "notPublishedYet": "앱이 아직 출시되지 않았습니다", "openInExplore": "Explore 에서 열기", diff --git a/web/i18n/nl-NL/app.json b/web/i18n/nl-NL/app.json index e62ead85e82..cbaa3641cf7 100644 --- a/web/i18n/nl-NL/app.json +++ b/web/i18n/nl-NL/app.json @@ -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", diff --git a/web/i18n/pl-PL/app.json b/web/i18n/pl-PL/app.json index 58156671cf7..024f71be62e 100644 --- a/web/i18n/pl-PL/app.json +++ b/web/i18n/pl-PL/app.json @@ -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", diff --git a/web/i18n/pt-BR/app.json b/web/i18n/pt-BR/app.json index 21e2437d1d2..a558fbb5d48 100644 --- a/web/i18n/pt-BR/app.json +++ b/web/i18n/pt-BR/app.json @@ -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", diff --git a/web/i18n/ro-RO/app.json b/web/i18n/ro-RO/app.json index a434a8e1a01..bbec39538c4 100644 --- a/web/i18n/ro-RO/app.json +++ b/web/i18n/ro-RO/app.json @@ -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ă", diff --git a/web/i18n/ru-RU/app.json b/web/i18n/ru-RU/app.json index 07ad6215a45..113f175ea95 100644 --- a/web/i18n/ru-RU/app.json +++ b/web/i18n/ru-RU/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "Рабочий процесс", "newAppFromTemplate.sidebar.Writing": "Пишущий", "noAccessPermission": "Нет разрешения на доступ к веб-приложению", + "noAccessResourcePermission": "Нет разрешения на доступ к этому ресурсу", "noUserInputNode": "Отсутствует узел ввода пользователя", "notPublishedYet": "Приложение ещё не опубликовано", "openInExplore": "Открыть в разделе «Обзор»", diff --git a/web/i18n/sl-SI/app.json b/web/i18n/sl-SI/app.json index f4599524d71..f9b21e2169f 100644 --- a/web/i18n/sl-SI/app.json +++ b/web/i18n/sl-SI/app.json @@ -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", diff --git a/web/i18n/th-TH/app.json b/web/i18n/th-TH/app.json index a6f5034b80c..ad7df05fccc 100644 --- a/web/i18n/th-TH/app.json +++ b/web/i18n/th-TH/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "เวิร์กโฟลว์", "newAppFromTemplate.sidebar.Writing": "การเขียน", "noAccessPermission": "ไม่มีสิทธิ์เข้าถึงเว็บแอป", + "noAccessResourcePermission": "ไม่มีสิทธิ์เข้าถึงทรัพยากรนี้", "noUserInputNode": "ไม่มีโหนดป้อนข้อมูลผู้ใช้", "notPublishedYet": "แอปยังไม่ได้เผยแพร่", "openInExplore": "เปิดใน Explore", diff --git a/web/i18n/tr-TR/app.json b/web/i18n/tr-TR/app.json index ffb77396d3c..df83be5099d 100644 --- a/web/i18n/tr-TR/app.json +++ b/web/i18n/tr-TR/app.json @@ -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ç", diff --git a/web/i18n/uk-UA/app.json b/web/i18n/uk-UA/app.json index 2bdcb3b71bb..8e094f1a976 100644 --- a/web/i18n/uk-UA/app.json +++ b/web/i18n/uk-UA/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "Робочий процес", "newAppFromTemplate.sidebar.Writing": "Написання", "noAccessPermission": "Немає дозволу на доступ до веб-додатку", + "noAccessResourcePermission": "Немає дозволу на доступ до цього ресурсу", "noUserInputNode": "Відсутній вузол введення користувача", "notPublishedYet": "Додаток ще не опублікований", "openInExplore": "Відкрити в Огляді", diff --git a/web/i18n/vi-VN/app.json b/web/i18n/vi-VN/app.json index 41ca574c10b..7f24485445a 100644 --- a/web/i18n/vi-VN/app.json +++ b/web/i18n/vi-VN/app.json @@ -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á", diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index 640b54e0987..7ccfc67174e 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "工作流", "newAppFromTemplate.sidebar.Writing": "写作", "noAccessPermission": "没有权限访问 web 应用", + "noAccessResourcePermission": "当前无权限访问该资源", "noUserInputNode": "缺少用户输入节点", "notPublishedYet": "应用暂未发布", "openInExplore": "在“探索”中打开", diff --git a/web/i18n/zh-Hant/app.json b/web/i18n/zh-Hant/app.json index b989a327960..5d5dc6e26a1 100644 --- a/web/i18n/zh-Hant/app.json +++ b/web/i18n/zh-Hant/app.json @@ -226,6 +226,7 @@ "newAppFromTemplate.sidebar.Workflow": "工作流", "newAppFromTemplate.sidebar.Writing": "寫作", "noAccessPermission": "沒有權限訪問網絡應用程式", + "noAccessResourcePermission": "目前沒有權限訪問此資源", "noUserInputNode": "缺少使用者輸入節點", "notPublishedYet": "應用程式尚未發布", "openInExplore": "在“探索”中打開", diff --git a/web/utils/app-redirection.spec.ts b/web/utils/app-redirection.spec.ts index d3d9cb02b9f..944cc893e3a 100644 --- a/web/utils/app-redirection.spec.ts +++ b/web/utils/app-redirection.spec.ts @@ -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', () => { diff --git a/web/utils/permission.spec.ts b/web/utils/permission.spec.ts index 311796cc14c..b6f8447ffc5 100644 --- a/web/utils/permission.spec.ts +++ b/web/utils/permission.spec.ts @@ -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) + }) }) }) diff --git a/web/utils/permission.ts b/web/utils/permission.ts index 733e58611ce..ddc3d7b2864 100644 --- a/web/utils/permission.ts +++ b/web/utils/permission.ts @@ -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), } }