mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor(access-config): scope app permissions to the page query (#43259)
This commit is contained in:
3 files changed
+113
-51
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import type { Locale } from '@/i18n'
|
||||
import { Suspense } from 'react'
|
||||
import AppAccessConfigPage from '@/app/components/app/access-config'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
|
||||
export type AccessConfigPageProps = {
|
||||
params: Promise<{ locale: Locale; appId: string }>
|
||||
@@ -10,7 +12,19 @@ const AccessConfig = async (props: AccessConfigPageProps) => {
|
||||
|
||||
const { appId } = params
|
||||
|
||||
return <AppAccessConfigPage appId={appId} />
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<SkeletonContainer className="gap-4 p-6" aria-busy="true">
|
||||
<SkeletonRectangle className="h-6 w-48" />
|
||||
<SkeletonRectangle className="h-16 w-full" />
|
||||
<SkeletonRectangle className="h-64 w-full" />
|
||||
</SkeletonContainer>
|
||||
}
|
||||
>
|
||||
<AppAccessConfigPage appId={appId} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessConfig
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { AccessRulesEditorProps } from '@/app/components/access-rules-editor'
|
||||
import { act, screen } from '@testing-library/react'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import ErrorBoundary from '@/app/components/base/error-boundary'
|
||||
import {
|
||||
useAppAccessRules,
|
||||
useAppResourceWhitelist,
|
||||
useAppResourceWhitelistConfig,
|
||||
useAppUserAccessSettings,
|
||||
} from '@/service/access-control/use-app-access-config'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { createAppDetailFixture } from '@/test/fixtures/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import AppAccessConfigPage from '../index'
|
||||
@@ -18,12 +20,20 @@ const mockConsoleState = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
let mockIsRbacEnabled = true
|
||||
let appDetail = createAppDetailFixture()
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
|
||||
renderWithConsoleQuery(ui, {
|
||||
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: appDetail.id } } }),
|
||||
appDetail,
|
||||
)
|
||||
return renderWithConsoleQuery(ui, {
|
||||
queryClient,
|
||||
accountProfile: mockConsoleState.userProfile,
|
||||
systemFeatures: { rbac_enabled: mockIsRbacEnabled },
|
||||
})
|
||||
}
|
||||
|
||||
const mockAppAccessRules = vi.hoisted(() => ({
|
||||
items: [] as AccessRulesEditorProps['rules'],
|
||||
@@ -143,12 +153,10 @@ describe('AppAccessConfigPage', () => {
|
||||
mockMutations.isUpdatingAutomaticIncludeWorkspaceMembers = false
|
||||
mockMutations.removeMemberBindingsAsync.mockResolvedValue(undefined)
|
||||
mockAccessRulesEditor.props = null
|
||||
useStore.setState({
|
||||
appDetail: {
|
||||
id: 'app-1',
|
||||
maintainer: 'account-1',
|
||||
permission_keys: [AppACLPermission.AccessConfig],
|
||||
} as unknown as NonNullable<ReturnType<typeof useStore.getState>['appDetail']>,
|
||||
appDetail = createAppDetailFixture({
|
||||
id: 'app-1',
|
||||
maintainer: 'account-1',
|
||||
permission_keys: [AppACLPermission.AccessConfig],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -341,12 +349,10 @@ describe('AppAccessConfigPage', () => {
|
||||
})
|
||||
|
||||
it('should not mount access config data hooks when access permission is missing', () => {
|
||||
useStore.setState({
|
||||
appDetail: {
|
||||
id: 'app-1',
|
||||
maintainer: 'account-1',
|
||||
permission_keys: [AppACLPermission.ViewLayout],
|
||||
} as NonNullable<ReturnType<typeof useStore.getState>['appDetail']>,
|
||||
appDetail = createAppDetailFixture({
|
||||
id: 'app-1',
|
||||
maintainer: 'account-1',
|
||||
permission_keys: [AppACLPermission.ViewLayout],
|
||||
})
|
||||
|
||||
render(<AppAccessConfigPage appId="app-1" />)
|
||||
@@ -367,13 +373,11 @@ describe('AppAccessConfigPage', () => {
|
||||
})
|
||||
|
||||
it('should not mount access config data hooks for Agent apps', () => {
|
||||
useStore.setState({
|
||||
appDetail: {
|
||||
id: 'app-1',
|
||||
mode: AppModeEnum.AGENT,
|
||||
maintainer: 'account-1',
|
||||
permission_keys: [AppACLPermission.AccessConfig],
|
||||
} as unknown as NonNullable<ReturnType<typeof useStore.getState>['appDetail']>,
|
||||
appDetail = createAppDetailFixture({
|
||||
id: 'app-1',
|
||||
mode: AppModeEnum.AGENT,
|
||||
maintainer: 'account-1',
|
||||
permission_keys: [AppACLPermission.AccessConfig],
|
||||
})
|
||||
|
||||
render(<AppAccessConfigPage appId="app-1" />)
|
||||
@@ -386,16 +390,59 @@ describe('AppAccessConfigPage', () => {
|
||||
it('should allow the maintainer with app management workspace permission', () => {
|
||||
mockConsoleState.userProfile = { id: 'account-1' }
|
||||
mockConsoleState.workspacePermissionKeys = ['app.create_and_management']
|
||||
useStore.setState({
|
||||
appDetail: {
|
||||
id: 'app-1',
|
||||
maintainer: 'account-1',
|
||||
permission_keys: [],
|
||||
} as unknown as NonNullable<ReturnType<typeof useStore.getState>['appDetail']>,
|
||||
appDetail = createAppDetailFixture({
|
||||
id: 'app-1',
|
||||
maintainer: 'account-1',
|
||||
permission_keys: [],
|
||||
})
|
||||
|
||||
render(<AppAccessConfigPage appId="app-1" />)
|
||||
|
||||
expect(screen.getByTestId('access-rules-editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the current member page on metadata refresh and stops rendering after permission revocation', async () => {
|
||||
appDetail = createAppDetailFixture({ permission_keys: [AppACLPermission.AccessConfig] })
|
||||
const { queryClient } = render(<AppAccessConfigPage appId="app-1" />)
|
||||
const queryKey = consoleQuery.apps.byAppId.get.queryKey({
|
||||
input: { params: { app_id: 'app-1' } },
|
||||
})
|
||||
act(() => mockAccessRulesEditor.props?.onPageChange?.(3))
|
||||
expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 3, 10)
|
||||
act(() => {
|
||||
queryClient.setQueryData(queryKey, { ...appDetail, maintainer: 'new-maintainer' })
|
||||
})
|
||||
await waitFor(() => expect(mockAccessRulesEditor.props?.maintainerId).toBe('new-maintainer'))
|
||||
expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 3, 10)
|
||||
act(() => {
|
||||
queryClient.setQueryData(queryKey, { ...appDetail, permission_keys: [] })
|
||||
})
|
||||
await waitFor(() => expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('surfaces an initial detail failure without mounting member queries', async () => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
const queryKey = consoleQuery.apps.byAppId.get.queryKey({
|
||||
input: { params: { app_id: 'unavailable-app' } },
|
||||
})
|
||||
queryClient.setQueryDefaults(queryKey, { retryOnMount: false })
|
||||
await queryClient
|
||||
.query({ queryKey, queryFn: () => Promise.reject(new Error('Detail unavailable')) })
|
||||
.catch(() => {})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
renderWithConsoleQuery(
|
||||
<ErrorBoundary>
|
||||
<AppAccessConfigPage appId="unavailable-app" />
|
||||
</ErrorBoundary>,
|
||||
{ queryClient },
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }),
|
||||
).toBeInTheDocument()
|
||||
expect(useAppAccessRules).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AccessPolicyMemberBindingRemoval } from '@/app/components/access-rules-editor'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useSuspenseQueries } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '#i18n'
|
||||
import AccessRulesEditor from '@/app/components/access-rules-editor'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useUpdateAppAutomaticIncludeWorkspaceMembers,
|
||||
useUpdateAppUserAccessSettings,
|
||||
} from '@/service/access-control/use-app-access-config'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getAppACLCapabilities } from '@/utils/permission'
|
||||
|
||||
@@ -31,7 +32,7 @@ type AppAccessConfigPageProps = {
|
||||
|
||||
type AppAccessConfigContentProps = {
|
||||
appId: string
|
||||
maintainerId?: string | null
|
||||
maintainerId: AppDetailWithSite['maintainer']
|
||||
}
|
||||
|
||||
const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentProps) => {
|
||||
@@ -233,38 +234,38 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP
|
||||
}
|
||||
|
||||
const AppAccessConfigPage = ({ appId }: AppAccessConfigPageProps) => {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: currentUserId } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => data.profile.id,
|
||||
})
|
||||
const [{ data: systemFeatures }, { data: userProfile }, { data: appDetail }] = useSuspenseQueries(
|
||||
{
|
||||
queries: [
|
||||
systemFeaturesQueryOptions(),
|
||||
userProfileQueryOptions(),
|
||||
consoleQuery.apps.byAppId.get.queryOptions({
|
||||
input: { params: { app_id: appId } },
|
||||
}),
|
||||
],
|
||||
},
|
||||
)
|
||||
const currentUserId = userProfile.profile.id
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const appDetail = useStore((state) => state.appDetail)
|
||||
const appACLCapabilities = useMemo(
|
||||
() =>
|
||||
getAppACLCapabilities(appDetail?.permission_keys, {
|
||||
getAppACLCapabilities(appDetail.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: appDetail?.maintainer,
|
||||
resourceMaintainer: appDetail.maintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
}),
|
||||
[
|
||||
appDetail?.maintainer,
|
||||
appDetail?.permission_keys,
|
||||
appDetail.maintainer,
|
||||
appDetail.permission_keys,
|
||||
currentUserId,
|
||||
isRbacEnabled,
|
||||
workspacePermissionKeys,
|
||||
],
|
||||
)
|
||||
|
||||
if (
|
||||
!appDetail ||
|
||||
appDetail.id !== appId ||
|
||||
appDetail.mode === AppModeEnum.AGENT ||
|
||||
!appACLCapabilities.canAccessConfig
|
||||
)
|
||||
return null
|
||||
if (appDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig) return null
|
||||
|
||||
return <AppAccessConfigContent appId={appId} maintainerId={appDetail.maintainer} />
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user