refactor(web): migrate plugins and tools app context consumers (#38533)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-07-08 14:26:56 +08:00 committed by GitHub
parent 98f67e9c82
commit 503d80be1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 745 additions and 189 deletions

View File

@ -3457,14 +3457,6 @@
"count": 2 "count": 2
} }
}, },
"web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": { "web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": {
"jsx-a11y/click-events-have-key-events": { "jsx-a11y/click-events-have-key-events": {
"count": 2 "count": 2

View File

@ -44,7 +44,13 @@ vi.mock('@/context/app-context', () => ({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { langGeniusVersionInfo: {
current_env: 'CLOUD',
current_version: '1.0.0', current_version: '1.0.0',
latest_version: '1.0.0',
version: '1.0.0',
release_date: '',
release_notes: '',
can_auto_update: false,
}, },
workspacePermissionKeys: [ workspacePermissionKeys: [
'plugin.install', 'plugin.install',
@ -54,6 +60,33 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: {
current_env: 'CLOUD',
current_version: '1.0.0',
latest_version: '1.0.0',
version: '1.0.0',
release_date: '',
release_notes: '',
can_auto_update: false,
},
workspacePermissionKeys: [
'plugin.install',
'plugin.delete',
'plugin.plugin_preferences',
],
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
hasPluginPermission: () => true, hasPluginPermission: () => true,
useReferenceSettings: () => ({ useReferenceSettings: () => ({

View File

@ -53,6 +53,19 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['tool.manage', 'credential.create', 'credential.manage', 'credential.use'],
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
const mockSetShowModelModal = vi.fn() const mockSetShowModelModal = vi.fn()
vi.mock('@/context/modal-context', () => ({ vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({ useModalContext: () => ({

View File

@ -14,6 +14,10 @@ export type AppContextStateMockState = {
currentWorkspace?: { currentWorkspace?: {
id?: string id?: string
} | null } | null
isCurrentWorkspaceManager?: boolean
isCurrentWorkspaceOwner?: boolean
isCurrentWorkspaceEditor?: boolean
isCurrentWorkspaceDatasetOperator?: boolean
isLoadingCurrentWorkspace?: boolean isLoadingCurrentWorkspace?: boolean
isLoadingWorkspacePermissionKeys?: boolean isLoadingWorkspacePermissionKeys?: boolean
workspacePermissionKeys?: string[] workspacePermissionKeys?: string[]
@ -25,6 +29,7 @@ type AppContextStateAtomKind
| 'userProfileId' | 'userProfileId'
| 'currentWorkspace' | 'currentWorkspace'
| 'currentWorkspaceId' | 'currentWorkspaceId'
| 'workspaceRoleFlags'
| 'currentWorkspaceLoading' | 'currentWorkspaceLoading'
| 'workspacePermissionKeys' | 'workspacePermissionKeys'
| 'workspacePermissionKeysLoading' | 'workspacePermissionKeysLoading'
@ -98,6 +103,7 @@ export const createAppContextStateAtomMock = async (
userProfileIdAtom: createMockAtom('userProfileId'), userProfileIdAtom: createMockAtom('userProfileId'),
currentWorkspaceAtom: createMockAtom('currentWorkspace'), currentWorkspaceAtom: createMockAtom('currentWorkspace'),
currentWorkspaceIdAtom: createMockAtom('currentWorkspaceId'), currentWorkspaceIdAtom: createMockAtom('currentWorkspaceId'),
workspaceRoleFlagsAtom: createMockAtom('workspaceRoleFlags'),
currentWorkspaceLoadingAtom: createMockAtom('currentWorkspaceLoading'), currentWorkspaceLoadingAtom: createMockAtom('currentWorkspaceLoading'),
workspacePermissionKeysAtom: createMockAtom('workspacePermissionKeys'), workspacePermissionKeysAtom: createMockAtom('workspacePermissionKeys'),
workspacePermissionKeysLoadingAtom: createMockAtom('workspacePermissionKeysLoading'), workspacePermissionKeysLoadingAtom: createMockAtom('workspacePermissionKeysLoading'),
@ -135,6 +141,15 @@ export const createAppContextStateJotaiMock = async (
if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceId') if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceId')
return currentWorkspace.id return currentWorkspace.id
if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'workspaceRoleFlags') {
return {
isCurrentWorkspaceManager: state.isCurrentWorkspaceManager ?? false,
isCurrentWorkspaceOwner: state.isCurrentWorkspaceOwner ?? false,
isCurrentWorkspaceEditor: state.isCurrentWorkspaceEditor ?? false,
isCurrentWorkspaceDatasetOperator: state.isCurrentWorkspaceDatasetOperator ?? false,
}
}
if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceLoading') if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceLoading')
return state.isLoadingCurrentWorkspace ?? false return state.isLoadingCurrentWorkspace ?? false

View File

@ -116,6 +116,19 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
isCurrentWorkspaceManager: true,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('@langgenius/dify-ui/toast', () => ({
toast: { toast: {
error: (...args: unknown[]) => mockToastError(...args), error: (...args: unknown[]) => mockToastError(...args),

View File

@ -31,6 +31,16 @@ vi.mock('@/context/app-context', async (importOriginal) => {
} }
}) })
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current ?? {})
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/next/navigation', () => ({ vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(() => ({ useRouter: vi.fn(() => ({
push: vi.fn(), push: vi.fn(),
@ -77,10 +87,14 @@ vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })), useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
})) }))
vi.mock('@/service/use-common', () => ({ vi.mock('@/service/use-common', async (importOriginal) => {
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })), const actual = await importOriginal<typeof import('@/service/use-common')>()
useProviderContext: vi.fn(), return {
})) ...actual,
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
useProviderContext: vi.fn(),
}
})
vi.mock('@/service/client', async (importOriginal) => { vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>() const actual = await importOriginal<typeof import('@/service/client')>()

View File

@ -19,6 +19,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/app/components/plugins/plugin-auth', () => ({ vi.mock('@/app/components/plugins/plugin-auth', () => ({
ApiKeyModal: vi.fn(({ onClose, onUpdate, onRemove, disabled, editValues }: { onClose: () => void, onUpdate: () => void, onRemove: () => void, disabled: boolean, editValues: Record<string, unknown> }) => ( ApiKeyModal: vi.fn(({ onClose, onUpdate, onRemove, disabled, editValues }: { onClose: () => void, onUpdate: () => void, onRemove: () => void, disabled: boolean, editValues: Record<string, unknown> }) => (
<div data-testid="mock-api-key-modal" data-disabled={disabled}> <div data-testid="mock-api-key-modal" data-disabled={disabled}>

View File

@ -13,6 +13,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
Authorized: ({ Authorized: ({
renderTrigger, renderTrigger,

View File

@ -34,6 +34,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
// Mock components // Mock components
vi.mock('../../model-icon', () => ({ vi.mock('../../model-icon', () => ({
default: () => <div data-testid="model-icon" />, default: () => <div data-testid="model-icon" />,

View File

@ -14,6 +14,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
// Mock components // Mock components
vi.mock('../authorized', () => ({ vi.mock('../authorized', () => ({
default: ({ default: ({
@ -37,7 +49,12 @@ vi.mock('../authorized', () => ({
data-hide-add-action={String(!!hideAddAction)} data-hide-add-action={String(!!hideAddAction)}
data-trigger-only-open-modal={String(!!triggerOnlyOpenModal)} data-trigger-only-open-modal={String(!!triggerOnlyOpenModal)}
> >
<div data-testid="trigger-container" onClick={() => onItemClick?.(items[0]!.credentials[0])}> <div
role="presentation"
data-testid="trigger-container"
onClick={() => onItemClick?.(items[0]!.credentials[0])}
onKeyDown={() => undefined}
>
{renderTrigger()} {renderTrigger()}
</div> </div>
</div> </div>

View File

@ -19,6 +19,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('../../hooks', () => ({ vi.mock('../../hooks', () => ({
useAuth: () => ({ useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete, openConfirmDelete: mockOpenConfirmDelete,

View File

@ -80,6 +80,18 @@ vi.mock('@/context/app-context', () => ({
selector({ workspacePermissionKeys: mockState.workspacePermissionKeys }), selector({ workspacePermissionKeys: mockState.workspacePermissionKeys }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockState.workspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/hooks/use-i18n', () => ({ vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: { en_US: string }) => value.en_US, useRenderI18nObject: () => (value: { en_US: string }) => value.en_US,
})) }))

View File

@ -94,6 +94,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({ const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4', model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },

View File

@ -43,6 +43,19 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
// Mock internal components to simplify testing of the index file // Mock internal components to simplify testing of the index file
vi.mock('../credential-panel', () => ({ vi.mock('../credential-panel', () => ({
default: () => <div data-testid="credential-panel" />, default: () => <div data-testid="credential-panel" />,

View File

@ -9,6 +9,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'],
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({ const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
credential_id: 'cred-1', credential_id: 'cred-1',
credential_name: 'Test API Key', credential_name: 'Test API Key',

View File

@ -41,6 +41,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('../../../model-auth/authorized/credential-item', () => ({ vi.mock('../../../model-auth/authorized/credential-item', () => ({
default: ({ credential, disabled, disableEdit, disableDelete, onItemClick, onEdit, onDelete }: { default: ({ credential, disabled, disableEdit, disableDelete, onItemClick, onEdit, onDelete }: {
credential: { credential_id: string, credential_name: string } credential: { credential_id: string, credential_name: string }

View File

@ -103,6 +103,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockAppContextState.workspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
let mockCheckedInstalledData: { plugins: { id: string, name: string }[] } | null = null let mockCheckedInstalledData: { plugins: { id: string, name: string }[] } | null = null
const mockInvalidateInstalledPluginList = vi.fn() const mockInvalidateInstalledPluginList = vi.fn()
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({

View File

@ -41,11 +41,17 @@ vi.mock('@/utils/format', () => ({
formatNumber: (num: number) => num.toLocaleString(), formatNumber: (num: number) => num.toLocaleString(),
})) }))
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context-state', async (importOriginal) => {
useSelector: (selector: (value: { currentWorkspace: { id: string } }) => string) => selector({ const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
currentWorkspace: { id: 'workspace-123' }, currentWorkspace: { id: 'workspace-123' },
}), }))
})) })
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/utils/mcp', () => ({ vi.mock('@/utils/mcp', () => ({
shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗', shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗',

View File

@ -1,9 +1,10 @@
'use client' 'use client'
import type { Plugin } from '../types' import type { Plugin } from '../types'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useAtomValue } from 'jotai'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from '#i18n' import { useTranslation } from '#i18n'
import { useSelector } from '@/context/app-context' import { currentWorkspaceIdAtom } from '@/context/app-context-state'
import { useGetLanguage } from '@/context/i18n' import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { import {
@ -61,7 +62,7 @@ const Card = ({
const locale = useGetLanguage() const locale = useGetLanguage()
const { t } = useTranslation() const { t } = useTranslation()
const { categoriesMap } = useCategories(true) const { categoriesMap } = useCategories(true)
const currentWorkspaceId = useSelector(s => s.currentWorkspace.id) const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload
const badges = payload.badges ?? [] const badges = payload.badges ?? []
const { theme } = useTheme() const { theme } = useTheme()

View File

@ -2,15 +2,27 @@ import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import useGetIcon from '../use-get-icon' import useGetIcon from '../use-get-icon'
const mockCurrentWorkspaceIdAtom = vi.hoisted(() => Symbol('currentWorkspaceIdAtom'))
vi.mock('@/config', () => ({ vi.mock('@/config', () => ({
API_PREFIX: 'https://api.example.com', API_PREFIX: 'https://api.example.com',
})) }))
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context-state', () => ({
useSelector: (selector: (state: { currentWorkspace: { id: string } }) => string | { id: string }) => currentWorkspaceIdAtom: mockCurrentWorkspaceIdAtom,
selector({ currentWorkspace: { id: 'workspace-123' } }),
})) }))
vi.mock('jotai', () => {
return {
useAtomValue: (atom: unknown) => {
if (atom === mockCurrentWorkspaceIdAtom)
return 'workspace-123'
throw new Error('Unexpected atom')
},
}
})
describe('useGetIcon', () => { describe('useGetIcon', () => {
it('builds icon url with current workspace id', () => { it('builds icon url with current workspace id', () => {
const { result } = renderHook(() => useGetIcon()) const { result } = renderHook(() => useGetIcon())

View File

@ -1,12 +1,13 @@
import { useAtomValue } from 'jotai'
import { useCallback } from 'react' import { useCallback } from 'react'
import { API_PREFIX } from '@/config' import { API_PREFIX } from '@/config'
import { useSelector } from '@/context/app-context' import { currentWorkspaceIdAtom } from '@/context/app-context-state'
const useGetIcon = () => { const useGetIcon = () => {
const currentWorkspace = useSelector(s => s.currentWorkspace) const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const getIconUrl = useCallback((fileName: string) => { const getIconUrl = useCallback((fileName: string) => {
return `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${currentWorkspace.id}&filename=${fileName}` return `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${currentWorkspaceId}&filename=${fileName}`
}, [currentWorkspace.id]) }, [currentWorkspaceId])
return { return {
getIconUrl, getIconUrl,

View File

@ -4,12 +4,26 @@ import useWorkspacePluginInstallPermission from '../use-workspace-plugin-install
let mockWorkspacePermissionKeys: string[] = [] let mockWorkspacePermissionKeys: string[] = []
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context-state', async (importOriginal) => {
useAppContext: () => ({ const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
langGeniusVersionInfo: { current_version: '1.0.0' }, return createAppContextStateAtomMock(importOriginal, () => ({
langGeniusVersionInfo: {
current_env: '',
current_version: '1.0.0',
latest_version: '',
release_date: '',
release_notes: '',
version: '',
can_auto_update: false,
},
workspacePermissionKeys: mockWorkspacePermissionKeys, workspacePermissionKeys: mockWorkspacePermissionKeys,
}), }))
})) })
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
describe('useWorkspacePluginInstallPermission', () => { describe('useWorkspacePluginInstallPermission', () => {
beforeEach(() => { beforeEach(() => {

View File

@ -1,12 +1,11 @@
import { useAtomValue } from 'jotai'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useAppContext } from '@/context/app-context' import { langGeniusVersionInfoAtom, workspacePermissionKeysAtom } from '@/context/app-context-state'
import { hasPermission } from '@/utils/permission' import { hasPermission } from '@/utils/permission'
const useWorkspacePluginInstallPermission = () => { const useWorkspacePluginInstallPermission = () => {
const { const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
langGeniusVersionInfo, const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
workspacePermissionKeys,
} = useAppContext()
const canInstallPlugin = useMemo(() => { const canInstallPlugin = useMemo(() => {
return hasPermission(workspacePermissionKeys, 'plugin.install') return hasPermission(workspacePermissionKeys, 'plugin.install')

View File

@ -57,11 +57,22 @@ vi.mock('../../../base/check-task-status', () => ({
}), }),
})) }))
const mockLangGeniusVersionInfo = { current_version: '1.0.0' } const mockAppContextState = vi.hoisted(() => ({
vi.mock('@/context/app-context', () => ({ langGeniusVersionInfoAtom: Symbol('langGeniusVersionInfoAtom'),
useAppContext: () => ({ langGeniusVersionInfo: { current_version: '1.0.0' as string | undefined },
langGeniusVersionInfo: mockLangGeniusVersionInfo, }))
}),
vi.mock('@/context/app-context-state', () => ({
langGeniusVersionInfoAtom: mockAppContextState.langGeniusVersionInfoAtom,
}))
vi.mock('jotai', () => ({
useAtomValue: (atom: unknown) => {
if (atom === mockAppContextState.langGeniusVersionInfoAtom)
return mockAppContextState.langGeniusVersionInfo
throw new Error('Unexpected atom')
},
})) }))
vi.mock('../../../../card', () => ({ vi.mock('../../../../card', () => ({
@ -466,7 +477,7 @@ describe('Install', () => {
// ================================ // ================================
describe('Dify Version Compatibility', () => { describe('Dify Version Compatibility', () => {
it('should not show warning when dify version is compatible', () => { it('should not show warning when dify version is compatible', () => {
mockLangGeniusVersionInfo.current_version = '1.0.0' mockAppContextState.langGeniusVersionInfo.current_version = '1.0.0'
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '0.8.0' } }) const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '0.8.0' } })
render(<Install {...defaultProps} payload={payload} />) render(<Install {...defaultProps} payload={payload} />)
@ -475,7 +486,7 @@ describe('Install', () => {
}) })
it('should show warning when dify version is incompatible', () => { it('should show warning when dify version is incompatible', () => {
mockLangGeniusVersionInfo.current_version = '1.0.0' mockAppContextState.langGeniusVersionInfo.current_version = '1.0.0'
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } }) const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
render(<Install {...defaultProps} payload={payload} />) render(<Install {...defaultProps} payload={payload} />)
@ -484,7 +495,7 @@ describe('Install', () => {
}) })
it('should be compatible when minimum_dify_version is undefined', () => { it('should be compatible when minimum_dify_version is undefined', () => {
mockLangGeniusVersionInfo.current_version = '1.0.0' mockAppContextState.langGeniusVersionInfo.current_version = '1.0.0'
const payload = createMockManifest({ meta: { version: '1.0.0' } }) const payload = createMockManifest({ meta: { version: '1.0.0' } })
render(<Install {...defaultProps} payload={payload} />) render(<Install {...defaultProps} payload={payload} />)
@ -493,7 +504,7 @@ describe('Install', () => {
}) })
it('should be compatible when current_version is empty', () => { it('should be compatible when current_version is empty', () => {
mockLangGeniusVersionInfo.current_version = '' mockAppContextState.langGeniusVersionInfo.current_version = ''
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } }) const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
render(<Install {...defaultProps} payload={payload} />) render(<Install {...defaultProps} payload={payload} />)
@ -503,7 +514,7 @@ describe('Install', () => {
}) })
it('should be compatible when current_version is undefined', () => { it('should be compatible when current_version is undefined', () => {
mockLangGeniusVersionInfo.current_version = undefined as unknown as string mockAppContextState.langGeniusVersionInfo.current_version = undefined as unknown as string
const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } }) const payload = createMockManifest({ meta: { version: '1.0.0', minimum_dify_version: '2.0.0' } })
render(<Install {...defaultProps} payload={payload} />) render(<Install {...defaultProps} payload={payload} />)

View File

@ -3,11 +3,12 @@ import type { FC } from 'react'
import type { PluginDeclaration } from '../../../types' import type { PluginDeclaration } from '../../../types'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { RiLoader2Line } from '@remixicon/react' import { RiLoader2Line } from '@remixicon/react'
import { useAtomValue } from 'jotai'
import * as React from 'react' import * as React from 'react'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
import { useAppContext } from '@/context/app-context' import { langGeniusVersionInfoAtom } from '@/context/app-context-state'
import { uninstallPlugin } from '@/service/plugins' import { uninstallPlugin } from '@/service/plugins'
import { useInstallPackageFromLocal, usePluginTaskList } from '@/service/use-plugins' import { useInstallPackageFromLocal, usePluginTaskList } from '@/service/use-plugins'
import { isEqualOrLaterThanVersion } from '@/utils/semver' import { isEqualOrLaterThanVersion } from '@/utils/semver'
@ -108,7 +109,7 @@ const Installed: FC<Props> = ({
} }
} }
const { langGeniusVersionInfo } = useAppContext() const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isDifyVersionCompatible = useMemo(() => { const isDifyVersionCompatible = useMemo(() => {
if (!langGeniusVersionInfo.current_version) if (!langGeniusVersionInfo.current_version)
return true return true

View File

@ -60,7 +60,10 @@ const mockStopTaskStatus = vi.fn()
const mockHandleInstallTaskStart = vi.fn() const mockHandleInstallTaskStart = vi.fn()
let mockPluginDeclaration: { manifest: { meta: { minimum_dify_version: string } } } | undefined let mockPluginDeclaration: { manifest: { meta: { minimum_dify_version: string } } } | undefined
let mockCanInstall = true let mockCanInstall = true
let mockLangGeniusVersionInfo = { current_version: '1.0.0' } const mockAppContextState = vi.hoisted(() => ({
langGeniusVersionInfoAtom: Symbol('langGeniusVersionInfoAtom'),
langGeniusVersionInfo: { current_version: '1.0.0' as string | null },
}))
// Mock useCheckInstalled // Mock useCheckInstalled
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({ vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
@ -71,10 +74,17 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', ()
}), }),
})) }))
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context-state', () => ({
useAppContext: () => ({ langGeniusVersionInfoAtom: mockAppContextState.langGeniusVersionInfoAtom,
langGeniusVersionInfo: mockLangGeniusVersionInfo, }))
}),
vi.mock('jotai', () => ({
useAtomValue: (atom: unknown) => {
if (atom === mockAppContextState.langGeniusVersionInfoAtom)
return mockAppContextState.langGeniusVersionInfo
throw new Error('Unexpected atom')
},
})) }))
// Mock service hooks // Mock service hooks
@ -104,7 +114,7 @@ vi.mock('../../../base/check-task-status', () => ({
vi.mock('@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission', () => ({ vi.mock('@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission', () => ({
default: () => ({ default: () => ({
canInstallPlugin: true, canInstallPlugin: true,
currentDifyVersion: mockLangGeniusVersionInfo.current_version, currentDifyVersion: mockAppContextState.langGeniusVersionInfo.current_version,
}), }),
})) }))
@ -170,7 +180,7 @@ describe('Install Component (steps/install.tsx)', () => {
mockIsLoading = false mockIsLoading = false
mockPluginDeclaration = undefined mockPluginDeclaration = undefined
mockCanInstall = true mockCanInstall = true
mockLangGeniusVersionInfo = { current_version: '1.0.0' } mockAppContextState.langGeniusVersionInfo = { current_version: '1.0.0' }
mockInstallPackageFromMarketPlace.mockResolvedValue({ mockInstallPackageFromMarketPlace.mockResolvedValue({
all_installed: false, all_installed: false,
task_id: 'task-123', task_id: 'task-123',
@ -281,7 +291,7 @@ describe('Install Component (steps/install.tsx)', () => {
}) })
it('should not show warning when dify version is compatible', () => { it('should not show warning when dify version is compatible', () => {
mockLangGeniusVersionInfo = { current_version: '2.0.0' } mockAppContextState.langGeniusVersionInfo = { current_version: '2.0.0' }
mockPluginDeclaration = { mockPluginDeclaration = {
manifest: { meta: { minimum_dify_version: '1.0.0' } }, manifest: { meta: { minimum_dify_version: '1.0.0' } },
} }
@ -291,7 +301,7 @@ describe('Install Component (steps/install.tsx)', () => {
}) })
it('should show warning when dify version is incompatible', () => { it('should show warning when dify version is incompatible', () => {
mockLangGeniusVersionInfo = { current_version: '1.0.0' } mockAppContextState.langGeniusVersionInfo = { current_version: '1.0.0' }
mockPluginDeclaration = { mockPluginDeclaration = {
manifest: { meta: { minimum_dify_version: '2.0.0' } }, manifest: { meta: { minimum_dify_version: '2.0.0' } },
} }
@ -749,7 +759,7 @@ describe('Install Component (steps/install.tsx)', () => {
}) })
it('should handle null current_version in langGeniusVersionInfo', () => { it('should handle null current_version in langGeniusVersionInfo', () => {
mockLangGeniusVersionInfo = { current_version: null as unknown as string } mockAppContextState.langGeniusVersionInfo = { current_version: null as unknown as string }
mockPluginDeclaration = { mockPluginDeclaration = {
manifest: { meta: { minimum_dify_version: '1.0.0' } }, manifest: { meta: { minimum_dify_version: '1.0.0' } },
} }

View File

@ -3,11 +3,12 @@ import type { FC } from 'react'
import type { InstallPackageResponse, Plugin, PluginManifestInMarket } from '../../../types' import type { InstallPackageResponse, Plugin, PluginManifestInMarket } from '../../../types'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { RiLoader2Line } from '@remixicon/react' import { RiLoader2Line } from '@remixicon/react'
import { useAtomValue } from 'jotai'
import * as React from 'react' import * as React from 'react'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
import { useAppContext } from '@/context/app-context' import { langGeniusVersionInfoAtom } from '@/context/app-context-state'
import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, usePluginTaskList, useUpdatePackageFromMarketPlace } from '@/service/use-plugins' import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, usePluginTaskList, useUpdatePackageFromMarketPlace } from '@/service/use-plugins'
import { isEqualOrLaterThanVersion } from '@/utils/semver' import { isEqualOrLaterThanVersion } from '@/utils/semver'
import Card from '../../../card' import Card from '../../../card'
@ -133,7 +134,7 @@ const Installed: FC<Props> = ({
} }
} }
const { langGeniusVersionInfo } = useAppContext() const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const { data: pluginDeclaration } = usePluginDeclarationFromMarketPlace(uniqueIdentifier) const { data: pluginDeclaration } = usePluginDeclarationFromMarketPlace(uniqueIdentifier)
const isDifyVersionCompatible = useMemo(() => { const isDifyVersionCompatible = useMemo(() => {
if (!pluginDeclaration || !langGeniusVersionInfo.current_version) if (!pluginDeclaration || !langGeniusVersionInfo.current_version)

View File

@ -1,8 +1,9 @@
'use client' 'use client'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useAtomValue } from 'jotai'
import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider' import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
import { useAppContext } from '@/context/app-context' import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { hasPermission } from '@/utils/permission' import { hasPermission } from '@/utils/permission'
type MarketplaceInstallPermissionProviderProps = { type MarketplaceInstallPermissionProviderProps = {
@ -12,7 +13,7 @@ type MarketplaceInstallPermissionProviderProps = {
const MarketplaceInstallPermissionProvider = ({ const MarketplaceInstallPermissionProvider = ({
children, children,
}: MarketplaceInstallPermissionProviderProps) => { }: MarketplaceInstallPermissionProviderProps) => {
const { workspacePermissionKeys } = useAppContext() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install') const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install')
return ( return (

View File

@ -50,6 +50,20 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: mockUserProfile,
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'],
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/hooks/use-oauth', () => ({ vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: vi.fn(), openOAuthPopup: vi.fn(),
})) }))

View File

@ -30,6 +30,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockAppContext.workspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/modal-context', () => ({ vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({ useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal, setShowAccountSettingModal: mockSetShowAccountSettingModal,

View File

@ -71,6 +71,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockAppContext.workspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
// Mock service/use-triggers - API service // Mock service/use-triggers - API service
vi.mock('@/service/use-triggers', () => ({ vi.mock('@/service/use-triggers', () => ({
useTriggerPluginDynamicOptions: () => ({ useTriggerPluginDynamicOptions: () => ({

View File

@ -86,6 +86,19 @@ vi.mock('@/context/app-context', () => ({
}) => unknown) => selector(mockAppContext), }) => unknown) => selector(mockAppContext),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: mockAppContext.userProfile,
workspacePermissionKeys: mockAppContext.workspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
// Mock service/use-triggers // Mock service/use-triggers
vi.mock('@/service/use-triggers', () => ({ vi.mock('@/service/use-triggers', () => ({
useTriggerPluginDynamicOptions: () => ({ useTriggerPluginDynamicOptions: () => ({

View File

@ -5,16 +5,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '../../types' import { CredentialTypeEnum } from '../../types'
import Item from '../item' import Item from '../item'
// Item uses useAppContextWithSelector(state => state.userProfile) for the vi.mock('@/context/app-context-state', async (importOriginal) => {
// borrowed-row heuristic; provide a minimal mock so the selector resolves. const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
const mockUserProfile = { id: 'test-user', name: 'Test User', email: 'test@example.com', avatar_url: '' } return createAppContextStateAtomMock(importOriginal, () => ({
vi.mock('@/context/app-context', () => ({ userProfile: { id: 'test-user' },
useSelector: (selector: (state: { userProfile: typeof mockUserProfile, workspacePermissionKeys: string[] }) => unknown) => workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'],
selector({ }))
userProfile: mockUserProfile, })
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'],
}), vi.mock('jotai', async (importOriginal) => {
})) const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
// ==================== Test Utilities ==================== // ==================== Test Utilities ====================

View File

@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { import {
RiInformationLine, RiInformationLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useAtomValue } from 'jotai'
import { import {
memo, memo,
useMemo, useMemo,
@ -15,7 +16,7 @@ import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { userProfileIdAtom } from '@/context/app-context-state'
import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
import { CredentialTypeEnum } from '../types' import { CredentialTypeEnum } from '../types'
@ -58,14 +59,14 @@ const Item = ({
const { canUseCredential, canManageCredential } = useCredentialPermissions() const { canUseCredential, canManageCredential } = useCredentialPermissions()
const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2 const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2
const isPersonal = credential.visibility === 'only_me' const isPersonal = credential.visibility === 'only_me'
const userProfile = useAppContextWithSelector(state => state.userProfile) const currentUserId = useAtomValue(userProfileIdAtom)
// Borrowed-from-teammate: the backend explicitly flagged this row as another member's // Borrowed-from-teammate: the backend explicitly flagged this row as another member's
// only_me credential, returned only because the current node still references it. // only_me credential, returned only because the current node still references it.
// Fallback heuristic (created_by mismatch on a selected row) is kept for backends // Fallback heuristic (created_by mismatch on a selected row) is kept for backends
// that don't yet emit the flag. // that don't yet emit the flag.
const isSelected = showSelectedIcon && selectedCredentialId === credential.id const isSelected = showSelectedIcon && selectedCredentialId === credential.id
const isConfiguredByOther const isConfiguredByOther
= !!credential.created_by && !!userProfile?.id && credential.created_by !== userProfile.id = !!credential.created_by && !!currentUserId && credential.created_by !== currentUserId
const isBorrowed const isBorrowed
= !!credential.from_other_member || (isSelected && isConfiguredByOther && isPersonal) = !!credential.from_other_member || (isSelected && isConfiguredByOther && isPersonal)
const showSwitchAwayHint = isBorrowed const showSwitchAwayHint = isBorrowed

View File

@ -1,4 +1,3 @@
// import { useAppContext } from '@/context/app-context'
// import { Button } from '@langgenius/dify-ui/button' // import { Button } from '@langgenius/dify-ui/button'
// import { StatusDot } from '@langgenius/dify-ui/status-dot' // import { StatusDot } from '@langgenius/dify-ui/status-dot'
// import ToolItem from '@/app/components/tools/provider/tool-item' // import ToolItem from '@/app/components/tools/provider/tool-item'
@ -18,7 +17,6 @@ const ActionList = ({
detail, detail,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
// const { isCurrentWorkspaceManager } = useAppContext()
// const providerBriefInfo = detail.declaration.datasource?.identity // const providerBriefInfo = detail.declaration.datasource?.identity
// const providerKey = `${detail.plugin_id}/${providerBriefInfo?.name}` // const providerKey = `${detail.plugin_id}/${providerBriefInfo?.name}`
const { data: dataSourceList } = useDataSourceList(true) const { data: dataSourceList } = useDataSourceList(true)

View File

@ -55,13 +55,36 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list',
})) }))
const mockLangGeniusVersionInfo = vi.fn(() => ({ const mockLangGeniusVersionInfo = vi.fn(() => ({
current_env: '',
current_version: '1.0.0', current_version: '1.0.0',
latest_version: '',
release_date: '',
release_notes: '',
version: '',
can_auto_update: false,
})) }))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ const createLangGeniusVersionInfo = (currentVersion: string) => ({
current_env: '',
current_version: currentVersion,
latest_version: '',
release_date: '',
release_notes: '',
version: '',
can_auto_update: false,
})
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
langGeniusVersionInfo: mockLangGeniusVersionInfo(), langGeniusVersionInfo: mockLangGeniusVersionInfo(),
}), }))
})) })
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('../action', () => ({ vi.mock('../action', () => ({
default: ({ onDelete, pluginName }: { onDelete: () => void, pluginName: string }) => ( default: ({ onDelete, pluginName }: { onDelete: () => void, pluginName: string }) => (
@ -162,7 +185,7 @@ describe('PluginItem', () => {
mockTheme.mockReturnValue('light') mockTheme.mockReturnValue('light')
mockCurrentPluginID.mockReturnValue(undefined) mockCurrentPluginID.mockReturnValue(undefined)
mockEnableMarketplace.mockReturnValue(true) mockEnableMarketplace.mockReturnValue(true)
mockLangGeniusVersionInfo.mockReturnValue({ current_version: '1.0.0' }) mockLangGeniusVersionInfo.mockReturnValue(createLangGeniusVersionInfo('1.0.0'))
mockGetValueFromI18nObject.mockImplementation((obj: Record<string, string>) => obj?.en_US || '') mockGetValueFromI18nObject.mockImplementation((obj: Record<string, string>) => obj?.en_US || '')
}) })
@ -359,7 +382,7 @@ describe('PluginItem', () => {
describe('Version Compatibility', () => { describe('Version Compatibility', () => {
it('should show warning icon when Dify version is not compatible', () => { it('should show warning icon when Dify version is not compatible', () => {
// Arrange // Arrange
mockLangGeniusVersionInfo.mockReturnValue({ current_version: '0.3.0' }) mockLangGeniusVersionInfo.mockReturnValue(createLangGeniusVersionInfo('0.3.0'))
const plugin = createPluginDetail({ const plugin = createPluginDetail({
declaration: createPluginDeclaration({ declaration: createPluginDeclaration({
meta: { version: '1.0.0', minimum_dify_version: '0.5.0' }, meta: { version: '1.0.0', minimum_dify_version: '0.5.0' },
@ -376,7 +399,7 @@ describe('PluginItem', () => {
it('should not show warning when Dify version is compatible', () => { it('should not show warning when Dify version is compatible', () => {
// Arrange // Arrange
mockLangGeniusVersionInfo.mockReturnValue({ current_version: '1.0.0' }) mockLangGeniusVersionInfo.mockReturnValue(createLangGeniusVersionInfo('1.0.0'))
const plugin = createPluginDetail({ const plugin = createPluginDetail({
declaration: createPluginDeclaration({ declaration: createPluginDeclaration({
meta: { version: '1.0.0', minimum_dify_version: '0.5.0' }, meta: { version: '1.0.0', minimum_dify_version: '0.5.0' },
@ -393,7 +416,7 @@ describe('PluginItem', () => {
it('should handle missing current_version gracefully', () => { it('should handle missing current_version gracefully', () => {
// Arrange // Arrange
mockLangGeniusVersionInfo.mockReturnValue({ current_version: '' }) mockLangGeniusVersionInfo.mockReturnValue(createLangGeniusVersionInfo(''))
const plugin = createPluginDetail() const plugin = createPluginDetail()
// Act // Act

View File

@ -11,12 +11,13 @@ import {
RiLoginCircleLine, RiLoginCircleLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react' import * as React from 'react'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { API_PREFIX } from '@/config' import { API_PREFIX } from '@/config'
import { useAppContext } from '@/context/app-context' import { langGeniusVersionInfoAtom } from '@/context/app-context-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRenderI18nObject } from '@/hooks/use-i18n' import { useRenderI18nObject } from '@/hooks/use-i18n'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
@ -69,7 +70,7 @@ const PluginItem: FC<Props> = ({
return [PluginSource.github, PluginSource.marketplace].includes(source) ? author : '' return [PluginSource.github, PluginSource.marketplace].includes(source) ? author : ''
}, [source, author]) }, [source, author])
const { langGeniusVersionInfo } = useAppContext() const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isDifyVersionCompatible = useMemo(() => { const isDifyVersionCompatible = useMemo(() => {
if (!langGeniusVersionInfo.current_version) if (!langGeniusVersionInfo.current_version)

View File

@ -51,11 +51,42 @@ vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ useAppContext: () => ({
isCurrentWorkspaceManager: true, isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0' }, langGeniusVersionInfo: {
current_env: 'CLOUD',
current_version: '1.0.0',
latest_version: '1.0.0',
version: '1.0.0',
release_date: '',
release_notes: '',
can_auto_update: false,
},
workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug', 'plugin.plugin_preferences'], workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug', 'plugin.plugin_preferences'],
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: {
current_env: 'CLOUD',
current_version: '1.0.0',
latest_version: '1.0.0',
version: '1.0.0',
release_date: '',
release_notes: '',
can_auto_update: false,
},
workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug', 'plugin.plugin_preferences'],
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
hasPluginPermission: (permission: string | undefined, isAdmin: boolean) => { hasPluginPermission: (permission: string | undefined, isAdmin: boolean) => {
if (!permission) if (!permission)

View File

@ -1,20 +1,49 @@
// Import mocks for assertions // Import mocks for assertions
import type { AppContextStateMockState } from '@/__tests__/utils/mock-app-context-state'
import type { LangGeniusVersionResponse } from '@/models/common'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { waitFor } from '@testing-library/react' import { waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHookWithSystemFeatures as renderHook } from '@/__tests__/utils/mock-system-features' import { renderHookWithSystemFeatures as renderHook } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
import { useInvalidateReferenceSettings, useMutationPluginPermissionSettings, useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings } from '@/service/use-plugins' import { useInvalidateReferenceSettings, useMutationPluginPermissionSettings, useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings } from '@/service/use-plugins'
import { PermissionType, PluginCategoryEnum } from '../../types' import { PermissionType, PluginCategoryEnum } from '../../types'
import useReferenceSetting, { useCanInstallPluginFromMarketplace } from '../use-reference-setting' import useReferenceSetting, { useCanInstallPluginFromMarketplace } from '../use-reference-setting'
vi.mock('@/context/app-context', async () => { const defaultLangGeniusVersionInfo: LangGeniusVersionResponse = {
const actual = await vi.importActual('@/context/app-context') current_env: '',
return { current_version: '1.0.0',
...actual, latest_version: '',
useAppContext: vi.fn(), release_date: '',
release_notes: '',
version: '',
can_auto_update: false,
}
type MockAppContextState = Omit<AppContextStateMockState, 'langGeniusVersionInfo'> & {
langGeniusVersionInfo?: Partial<LangGeniusVersionResponse>
}
let mockAppContextState: AppContextStateMockState = {}
const setAppContextState = (state: MockAppContextState) => {
mockAppContextState = {
...state,
langGeniusVersionInfo: {
...defaultLangGeniusVersionInfo,
...state.langGeniusVersionInfo,
},
} }
}
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
}) })
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
@ -33,12 +62,12 @@ describe('useReferenceSetting Hook', () => {
toastSuccessSpy.mockClear() toastSuccessSpy.mockClear()
// Default mocks // Default mocks
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[], workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginAutoUpgradeSettings).mockReturnValue({ vi.mocked(usePluginAutoUpgradeSettings).mockReturnValue({
data: { data: {
@ -109,12 +138,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should allow install and debug when plugin permission keys are present', () => { it('should allow install and debug when plugin permission keys are present', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install', 'plugin.debug'], workspacePermissionKeys: ['plugin.install', 'plugin.debug'],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginPermissionSettings).mockReturnValue({ vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: { data: {
install_permission: PermissionType.everyone, install_permission: PermissionType.everyone,
@ -129,12 +158,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should allow debug for managers with legacy admin permission when RBAC is disabled', () => { it('should allow debug for managers with legacy admin permission when RBAC is disabled', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: true, isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[], workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginPermissionSettings).mockReturnValue({ vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: { data: {
@ -150,12 +179,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should allow debug for owners with legacy admin permission when RBAC is disabled', () => { it('should allow debug for owners with legacy admin permission when RBAC is disabled', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: true, isCurrentWorkspaceOwner: true,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[], workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginPermissionSettings).mockReturnValue({ vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: { data: {
@ -171,12 +200,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should allow debug for normal users when legacy debug permission is everyone and RBAC is disabled', () => { it('should allow debug for normal users when legacy debug permission is everyone and RBAC is disabled', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install'], workspacePermissionKeys: ['plugin.install'],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginPermissionSettings).mockReturnValue({ vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: { data: {
@ -194,12 +223,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should use plugin keys even when legacy admin permission is configured and RBAC is enabled', () => { it('should use plugin keys even when legacy admin permission is configured and RBAC is enabled', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install', 'plugin.debug'], workspacePermissionKeys: ['plugin.install', 'plugin.debug'],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginPermissionSettings).mockReturnValue({ vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: { data: {
@ -217,12 +246,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should apply legacy noOne plugin permissions when RBAC is disabled', () => { it('should apply legacy noOne plugin permissions when RBAC is disabled', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: true, isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug'], workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.debug'],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginPermissionSettings).mockReturnValue({ vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: { data: {
install_permission: PermissionType.noOne, install_permission: PermissionType.noOne,
@ -245,12 +274,12 @@ describe('useReferenceSetting Hook', () => {
describe('canSetPermissions', () => { describe('canSetPermissions', () => {
it('should be true with plugin preferences permission when RBAC is disabled', () => { it('should be true with plugin preferences permission when RBAC is disabled', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.plugin_preferences'], workspacePermissionKeys: ['plugin.plugin_preferences'],
} as ReturnType<typeof useAppContext>) })
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool)) const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
@ -258,12 +287,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should be false when RBAC is enabled even with plugin preferences permission', () => { it('should be false when RBAC is enabled even with plugin preferences permission', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: true, isCurrentWorkspaceOwner: true,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.plugin_preferences'], workspacePermissionKeys: ['plugin.plugin_preferences'],
} as ReturnType<typeof useAppContext>) })
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool), { const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool), {
systemFeatures: { rbac_enabled: true }, systemFeatures: { rbac_enabled: true },
@ -274,12 +303,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should be false without plugin preferences permission', () => { it('should be false without plugin preferences permission', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: true, isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[], workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>) })
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool)) const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
@ -353,12 +382,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should keep permission key access available when reference setting data is still loading', () => { it('should keep permission key access available when reference setting data is still loading', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install', 'plugin.debug'], workspacePermissionKeys: ['plugin.install', 'plugin.debug'],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginAutoUpgradeSettings).mockReturnValue({ vi.mocked(usePluginAutoUpgradeSettings).mockReturnValue({
data: undefined, data: undefined,
} as unknown as ReturnType<typeof usePluginAutoUpgradeSettings>) } as unknown as ReturnType<typeof usePluginAutoUpgradeSettings>)
@ -371,13 +400,13 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should keep permission state loading while workspace permission keys are loading', () => { it('should keep permission state loading while workspace permission keys are loading', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
isLoadingWorkspacePermissionKeys: true, isLoadingWorkspacePermissionKeys: true,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[], workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>) })
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool)) const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
@ -386,13 +415,13 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should keep permission state loading while current workspace is loading', () => { it('should keep permission state loading while current workspace is loading', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
isLoadingCurrentWorkspace: true, isLoadingCurrentWorkspace: true,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install'], workspacePermissionKeys: ['plugin.install'],
} as ReturnType<typeof useAppContext>) })
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool)) const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
@ -402,7 +431,7 @@ describe('useReferenceSetting Hook', () => {
describe('RBAC permissions', () => { describe('RBAC permissions', () => {
it('should use workspace permission keys when RBAC is enabled', () => { it('should use workspace permission keys when RBAC is enabled', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
@ -412,7 +441,7 @@ describe('useReferenceSetting Hook', () => {
'plugin.debug', 'plugin.debug',
'plugin.plugin_preferences', 'plugin.plugin_preferences',
], ],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginPermissionSettings).mockReturnValue({ vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: { data: {
install_permission: PermissionType.noOne, install_permission: PermissionType.noOne,
@ -435,12 +464,12 @@ describe('useReferenceSetting Hook', () => {
}) })
it('should ignore legacy plugin permission settings when RBAC is enabled', () => { it('should ignore legacy plugin permission settings when RBAC is enabled', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: true, isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[], workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>) })
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool), { const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool), {
systemFeatures: { rbac_enabled: true }, systemFeatures: { rbac_enabled: true },
@ -462,12 +491,12 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: true, isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install'], workspacePermissionKeys: ['plugin.install'],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginPermissionSettings).mockReturnValue({ vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: { data: {
@ -501,12 +530,12 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
}) })
it('should return false without plugin.install', () => { it('should return false without plugin.install', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: true, isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[], workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>) })
const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), { const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), {
systemFeatures: { enable_marketplace: true }, systemFeatures: { enable_marketplace: true },
@ -516,12 +545,12 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
}) })
it('should return false when both marketplace is disabled and plugin.install is missing', () => { it('should return false when both marketplace is disabled and plugin.install is missing', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: true, isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: [] as string[], workspacePermissionKeys: [] as string[],
} as ReturnType<typeof useAppContext>) })
const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), { const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), {
systemFeatures: { enable_marketplace: false }, systemFeatures: { enable_marketplace: false },
@ -558,12 +587,12 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
}) })
it('should use plugin.install when marketplace and RBAC are enabled', () => { it('should use plugin.install when marketplace and RBAC are enabled', () => {
vi.mocked(useAppContext).mockReturnValue({ setAppContextState({
isCurrentWorkspaceManager: false, isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false, isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
workspacePermissionKeys: ['plugin.install'], workspacePermissionKeys: ['plugin.install'],
} as ReturnType<typeof useAppContext>) })
vi.mocked(usePluginPermissionSettings).mockReturnValue({ vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: { data: {
install_permission: PermissionType.noOne, install_permission: PermissionType.noOne,

View File

@ -1,16 +1,23 @@
import type { PluginCategoryEnum } from '../types' import type { PluginCategoryEnum } from '../types'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context' import {
currentWorkspaceLoadingAtom,
langGeniusVersionInfoAtom,
workspacePermissionKeysAtom,
workspacePermissionKeysLoadingAtom,
workspaceRoleFlagsAtom,
} from '@/context/app-context-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useInvalidateReferenceSettings, useMutationPluginPermissionSettings, useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings } from '@/service/use-plugins' import { useInvalidateReferenceSettings, useMutationPluginPermissionSettings, useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings } from '@/service/use-plugins'
import { hasPermission } from '@/utils/permission' import { hasPermission } from '@/utils/permission'
import { hasLegacyPluginPermissionAccess } from '../plugin-permissions' import { hasLegacyPluginPermissionAccess } from '../plugin-permissions'
const useCanSetPluginSettings = () => { const useCanSetPluginSettings = () => {
const { workspacePermissionKeys } = useAppContext() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { data: rbacEnabled } = useSuspenseQuery({ const { data: rbacEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(), ...systemFeaturesQueryOptions(),
select: s => s.rbac_enabled, select: s => s.rbac_enabled,
@ -28,11 +35,11 @@ export const usePluginSettingsAccess = () => {
const { const {
isCurrentWorkspaceManager, isCurrentWorkspaceManager,
isCurrentWorkspaceOwner, isCurrentWorkspaceOwner,
isLoadingCurrentWorkspace, } = useAtomValue(workspaceRoleFlagsAtom)
isLoadingWorkspacePermissionKeys, const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
workspacePermissionKeys, const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
langGeniusVersionInfo, const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
} = useAppContext() const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const { data: rbacEnabled } = useSuspenseQuery({ const { data: rbacEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(), ...systemFeaturesQueryOptions(),
select: s => s.rbac_enabled, select: s => s.rbac_enabled,
@ -125,7 +132,11 @@ export const useCanInstallPluginFromMarketplace = () => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const marketplaceAccess = systemFeatures.enable_marketplace const marketplaceAccess = systemFeatures.enable_marketplace
const rbacEnabled = systemFeatures.rbac_enabled const rbacEnabled = systemFeatures.rbac_enabled
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner, workspacePermissionKeys } = useAppContext() const {
isCurrentWorkspaceManager,
isCurrentWorkspaceOwner,
} = useAtomValue(workspaceRoleFlagsAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const permissionQuery = usePluginPermissionSettings() const permissionQuery = usePluginPermissionSettings()
const { data: permissions } = permissionQuery const { data: permissions } = permissionQuery
const legacyCanInstallPlugin = hasLegacyPluginPermissionAccess({ const legacyCanInstallPlugin = hasLegacyPluginPermissionAccess({

View File

@ -1,16 +1,17 @@
'use client' 'use client'
import { useSelector as useAppContextSelector } from '@/context/app-context' import { useAtomValue } from 'jotai'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { hasPermission } from '@/utils/permission' import { hasPermission } from '@/utils/permission'
export const useCanManageTools = () => { export const useCanManageTools = () => {
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
return hasPermission(workspacePermissionKeys, 'tool.manage') return hasPermission(workspacePermissionKeys, 'tool.manage')
} }
export const useCanManageMCP = () => { export const useCanManageMCP = () => {
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
return hasPermission(workspacePermissionKeys, 'mcp.manage') return hasPermission(workspacePermissionKeys, 'mcp.manage')
} }

View File

@ -40,14 +40,30 @@ vi.mock('../modal', () => ({
}, },
})) }))
let mockWorkspacePermissionKeys: string[] = ['mcp.manage'] const mockAppContextState = vi.hoisted(() => ({
workspacePermissionKeys: ['mcp.manage'] as string[],
workspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'),
}))
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys, workspacePermissionKeys: mockAppContextState.workspacePermissionKeys,
}), }),
})) }))
vi.mock('@/context/app-context-state', () => ({
workspacePermissionKeysAtom: mockAppContextState.workspacePermissionKeysAtom,
}))
vi.mock('jotai', () => ({
useAtomValue: (atom: unknown) => {
if (atom === mockAppContextState.workspacePermissionKeysAtom)
return mockAppContextState.workspacePermissionKeys
throw new Error('Unexpected atom')
},
}))
// Mock the plugins service // Mock the plugins service
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({ useInstalledPluginList: () => ({
@ -84,7 +100,7 @@ describe('NewMCPCard', () => {
beforeEach(() => { beforeEach(() => {
mockCreateMCP.mockClear() mockCreateMCP.mockClear()
mockWorkspacePermissionKeys = ['mcp.manage'] mockAppContextState.workspacePermissionKeys = ['mcp.manage']
}) })
describe('Rendering', () => { describe('Rendering', () => {
@ -152,7 +168,7 @@ describe('NewMCPCard', () => {
describe('mcp.manage Permission', () => { describe('mcp.manage Permission', () => {
it('should not render card when user lacks mcp.manage', () => { it('should not render card when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() }) render(<NewMCPCard {...defaultProps} />, { wrapper: createWrapper() })
@ -160,7 +176,7 @@ describe('NewMCPCard', () => {
}) })
it('should not render toolbar button when user lacks mcp.manage', () => { it('should not render toolbar button when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
render(<NewMCPButton {...defaultProps} />, { wrapper: createWrapper() }) render(<NewMCPButton {...defaultProps} />, { wrapper: createWrapper() })

View File

@ -15,7 +15,10 @@ const mockRefetch = vi.fn()
const mockUseAllToolProviders = vi.fn() const mockUseAllToolProviders = vi.fn()
let mockProviders: MockProvider[] = [] let mockProviders: MockProvider[] = []
let mockIsLoadingToolProviders = false let mockIsLoadingToolProviders = false
let mockWorkspacePermissionKeys = ['mcp.manage'] const mockAppContextState = vi.hoisted(() => ({
workspacePermissionKeys: ['mcp.manage'] as string[],
workspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'),
}))
vi.mock('@/service/use-tools', () => ({ vi.mock('@/service/use-tools', () => ({
useAllToolProviders: (enabled?: boolean) => { useAllToolProviders: (enabled?: boolean) => {
@ -30,10 +33,23 @@ vi.mock('@/service/use-tools', () => ({
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys, workspacePermissionKeys: mockAppContextState.workspacePermissionKeys,
}), }),
})) }))
vi.mock('@/context/app-context-state', () => ({
workspacePermissionKeysAtom: mockAppContextState.workspacePermissionKeysAtom,
}))
vi.mock('jotai', () => ({
useAtomValue: (atom: unknown) => {
if (atom === mockAppContextState.workspacePermissionKeysAtom)
return mockAppContextState.workspacePermissionKeys
throw new Error('Unexpected atom')
},
}))
vi.mock('@/app/components/tools/provider/tool-card-skeleton', () => ({ vi.mock('@/app/components/tools/provider/tool-card-skeleton', () => ({
default: ({ variant }: { variant?: string }) => ( default: ({ variant }: { variant?: string }) => (
<> <>
@ -89,7 +105,7 @@ describe('MCPList', () => {
vi.useFakeTimers() vi.useFakeTimers()
mockProviders = [] mockProviders = []
mockIsLoadingToolProviders = false mockIsLoadingToolProviders = false
mockWorkspacePermissionKeys = ['mcp.manage'] mockAppContextState.workspacePermissionKeys = ['mcp.manage']
mockRefetch.mockResolvedValue(undefined) mockRefetch.mockResolvedValue(undefined)
}) })
@ -111,7 +127,7 @@ describe('MCPList', () => {
}) })
it('should render providers read-only when user lacks mcp.manage', () => { it('should render providers read-only when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
mockProviders = [ mockProviders = [
{ id: '1', name: 'Provider 1', type: 'mcp' }, { id: '1', name: 'Provider 1', type: 'mcp' },
] ]

View File

@ -81,14 +81,30 @@ vi.mock('../detail/operation-dropdown', () => ({
), ),
})) }))
let mockWorkspacePermissionKeys: string[] = ['mcp.manage'] const mockAppContextState = vi.hoisted(() => ({
workspacePermissionKeys: ['mcp.manage'] as string[],
workspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'),
}))
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys, workspacePermissionKeys: mockAppContextState.workspacePermissionKeys,
}), }),
})) }))
vi.mock('@/context/app-context-state', () => ({
workspacePermissionKeysAtom: mockAppContextState.workspacePermissionKeysAtom,
}))
vi.mock('jotai', () => ({
useAtomValue: (atom: unknown) => {
if (atom === mockAppContextState.workspacePermissionKeysAtom)
return mockAppContextState.workspacePermissionKeys
throw new Error('Unexpected atom')
},
}))
// Mock the format time hook // Mock the format time hook
vi.mock('@/hooks/use-format-time-from-now', () => ({ vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({ useFormatTimeFromNow: () => ({
@ -155,7 +171,7 @@ describe('MCPCard', () => {
mockDeleteMCP.mockClear() mockDeleteMCP.mockClear()
mockUpdateMCP.mockResolvedValue({ result: 'success' }) mockUpdateMCP.mockResolvedValue({ result: 'success' })
mockDeleteMCP.mockResolvedValue({ result: 'success' }) mockDeleteMCP.mockResolvedValue({ result: 'success' })
mockWorkspacePermissionKeys = ['mcp.manage'] mockAppContextState.workspacePermissionKeys = ['mcp.manage']
}) })
describe('Rendering', () => { describe('Rendering', () => {
@ -343,7 +359,7 @@ describe('MCPCard', () => {
}) })
it('should not render operation dropdown when user lacks mcp.manage', () => { it('should not render operation dropdown when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() }) render(<MCPCard {...defaultProps} />, { wrapper: createWrapper() })

View File

@ -107,16 +107,31 @@ vi.mock('../tool-item', () => ({
), ),
})) }))
// Mutable workspace permission state const mockAppContextState = vi.hoisted(() => ({
let mockWorkspacePermissionKeys: string[] = ['mcp.manage'] workspacePermissionKeys: ['mcp.manage'] as string[],
workspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'),
}))
// Mock the app context // Mock the app context
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys, workspacePermissionKeys: mockAppContextState.workspacePermissionKeys,
}), }),
})) }))
vi.mock('@/context/app-context-state', () => ({
workspacePermissionKeysAtom: mockAppContextState.workspacePermissionKeysAtom,
}))
vi.mock('jotai', () => ({
useAtomValue: (atom: unknown) => {
if (atom === mockAppContextState.workspacePermissionKeysAtom)
return mockAppContextState.workspacePermissionKeys
throw new Error('Unexpected atom')
},
}))
// Mock the plugins service // Mock the plugins service
vi.mock('@/service/use-plugins', () => ({ vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({ useInstalledPluginList: () => ({
@ -195,7 +210,7 @@ describe('MCPDetailContent', () => {
mockIsFetching = false mockIsFetching = false
mockIsUpdating = false mockIsUpdating = false
mockIsAuthorizing = false mockIsAuthorizing = false
mockWorkspacePermissionKeys = ['mcp.manage'] mockAppContextState.workspacePermissionKeys = ['mcp.manage']
}) })
describe('Rendering', () => { describe('Rendering', () => {
@ -232,7 +247,7 @@ describe('MCPDetailContent', () => {
}) })
it('should render read-only detail when user lacks mcp.manage', () => { it('should render read-only detail when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() }) render(<MCPDetailContent {...defaultProps} />, { wrapper: createWrapper() })
@ -466,7 +481,7 @@ describe('MCPDetailContent', () => {
}) })
it('should disable authorize action when user lacks mcp.manage', () => { it('should disable authorize action when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
const detail = createMockDetail({ is_team_authorization: false }) const detail = createMockDetail({ is_team_authorization: false })
render( render(
<MCPDetailContent {...defaultProps} detail={detail} />, <MCPDetailContent {...defaultProps} detail={detail} />,
@ -756,7 +771,7 @@ describe('MCPDetailContent', () => {
}) })
it('should not run OAuth authorization when user lacks mcp.manage', async () => { it('should not run OAuth authorization when user lacks mcp.manage', async () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' }) mockAuthorizeMcp.mockResolvedValue({ authorization_url: 'https://oauth.example.com' })
const detail = createMockDetail({ is_team_authorization: false }) const detail = createMockDetail({ is_team_authorization: false })
@ -800,7 +815,7 @@ describe('MCPDetailContent', () => {
}) })
it('should disable authorized button when user lacks mcp.manage', () => { it('should disable authorized button when user lacks mcp.manage', () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
const detail = createMockDetail({ is_team_authorization: true }) const detail = createMockDetail({ is_team_authorization: true })
render( render(
<MCPDetailContent {...defaultProps} detail={detail} />, <MCPDetailContent {...defaultProps} detail={detail} />,

View File

@ -2,9 +2,10 @@
import type { AppDetailResponse } from '@/models/app' import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app' import type { AppSSO } from '@/types/app'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { BlockEnum } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types'
import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state'
import { fetchAppDetail } from '@/service/apps' import { fetchAppDetail } from '@/service/apps'
import { import {
useInvalidateMCPServerDetail, useInvalidateMCPServerDetail,
@ -36,8 +37,8 @@ export const useMCPServiceCardState = (
const { mutateAsync: updateMCPServer } = useUpdateMCPServer() const { mutateAsync: updateMCPServer } = useUpdateMCPServer()
const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode() const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode()
const invalidateMCPServerDetail = useInvalidateMCPServerDetail() const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageMCP = useMemo( const canManageMCP = useMemo(
() => getAppACLCapabilities(appInfo.permission_keys, { () => getAppACLCapabilities(appInfo.permission_keys, {

View File

@ -4,14 +4,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthType } from '../../types' import { AuthType } from '../../types'
import CustomCreateCard, { NewCustomToolButton } from '../custom-create-card' import CustomCreateCard, { NewCustomToolButton } from '../custom-create-card'
let mockWorkspacePermissionKeys: string[] = ['tool.manage'] const mockAppContextState = vi.hoisted(() => ({
workspacePermissionKeys: ['tool.manage'] as string[],
workspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'),
}))
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({ useSelector: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({
workspacePermissionKeys: mockWorkspacePermissionKeys, workspacePermissionKeys: mockAppContextState.workspacePermissionKeys,
}), }),
})) }))
vi.mock('@/context/app-context-state', () => ({
workspacePermissionKeysAtom: mockAppContextState.workspacePermissionKeysAtom,
}))
vi.mock('jotai', () => ({
useAtomValue: (atom: unknown) => {
if (atom === mockAppContextState.workspacePermissionKeysAtom)
return mockAppContextState.workspacePermissionKeys
throw new Error('Unexpected atom')
},
}))
// Mock useLocale and useDocLink // Mock useLocale and useDocLink
vi.mock('@/context/i18n', () => ({ vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US', useLocale: () => 'en-US',
@ -81,7 +97,7 @@ describe('CustomCreateCard', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockWorkspacePermissionKeys = ['tool.manage'] mockAppContextState.workspacePermissionKeys = ['tool.manage']
mockModalVisible = false mockModalVisible = false
mockCreateCustomCollection.mockResolvedValue({}) mockCreateCustomCollection.mockResolvedValue({})
}) })
@ -94,7 +110,7 @@ describe('CustomCreateCard', () => {
}) })
it('should not render anything when user does not have tool.manage', () => { it('should not render anything when user does not have tool.manage', () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
const { container } = render(<CustomCreateCard onRefreshData={mockOnRefreshData} />) const { container } = render(<CustomCreateCard onRefreshData={mockOnRefreshData} />)
@ -146,7 +162,7 @@ describe('CustomCreateCard', () => {
}) })
it('should not render toolbar add button when user does not have tool.manage', () => { it('should not render toolbar add button when user does not have tool.manage', () => {
mockWorkspacePermissionKeys = [] mockAppContextState.workspacePermissionKeys = []
const { container } = render(<NewCustomToolButton onRefreshData={mockOnRefreshData} />) const { container } = render(<NewCustomToolButton onRefreshData={mockOnRefreshData} />)

View File

@ -15,6 +15,7 @@ vi.mock('@/i18n-config/language', () => ({
const mockIsCurrentWorkspaceManager = vi.fn(() => true) const mockIsCurrentWorkspaceManager = vi.fn(() => true)
const mockAppContextState = vi.hoisted(() => ({ const mockAppContextState = vi.hoisted(() => ({
workspacePermissionKeys: ['tool.manage', 'credential.use', 'credential.create', 'credential.manage'] as string[], workspacePermissionKeys: ['tool.manage', 'credential.use', 'credential.create', 'credential.manage'] as string[],
workspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'),
})) }))
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ useAppContext: () => ({
@ -25,6 +26,19 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', () => ({
workspacePermissionKeysAtom: mockAppContextState.workspacePermissionKeysAtom,
}))
vi.mock('jotai', () => ({
useAtomValue: (atom: unknown) => {
if (atom === mockAppContextState.workspacePermissionKeysAtom)
return mockAppContextState.workspacePermissionKeys
throw new Error('Unexpected atom')
},
}))
const mockSetShowModelModal = vi.fn() const mockSetShowModelModal = vi.fn()
vi.mock('@/context/modal-context', () => ({ vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({ useModalContext: () => ({

View File

@ -69,6 +69,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/hooks/use-theme', () => ({ vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(), default: vi.fn(),
})) }))

View File

@ -21,6 +21,18 @@ vi.mock('@/context/app-context', () => ({
}), }),
})) }))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/use-tools', () => ({ vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled), useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled),
useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled), useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled),

View File

@ -1,13 +1,19 @@
import { renderHook } from '@testing-library/react' import { renderHook } from '@testing-library/react'
import { useCredentialPermissions } from './use-credential-permissions' import { useCredentialPermissions } from './use-credential-permissions'
let mockWorkspacePermissionKeys: string[] | null = [] let mockWorkspacePermissionKeys: string[] = []
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context-state', async (importOriginal) => {
useSelector: (selector: (state: { workspacePermissionKeys: string[] | null }) => unknown) => selector({ const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys, workspacePermissionKeys: mockWorkspacePermissionKeys,
}), }))
})) })
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
describe('useCredentialPermissions', () => { describe('useCredentialPermissions', () => {
beforeEach(() => { beforeEach(() => {
@ -39,8 +45,8 @@ describe('useCredentialPermissions', () => {
}) })
}) })
it('should handle missing workspace permissions as no credential capabilities', () => { it('should handle empty workspace permissions as no credential capabilities', () => {
mockWorkspacePermissionKeys = null mockWorkspacePermissionKeys = []
const { result } = renderHook(() => useCredentialPermissions()) const { result } = renderHook(() => useCredentialPermissions())

View File

@ -1,8 +1,9 @@
import { useSelector as useAppContextSelector } from '@/context/app-context' import { useAtomValue } from 'jotai'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { hasPermission } from '@/utils/permission' import { hasPermission } from '@/utils/permission'
export const useCredentialPermissions = () => { export const useCredentialPermissions = () => {
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
return { return {
canUseCredential: hasPermission(workspacePermissionKeys, 'credential.use'), canUseCredential: hasPermission(workspacePermissionKeys, 'credential.use'),

View File

@ -19,9 +19,11 @@ import {
const { const {
mockGet, mockGet,
mockPost, mockPost,
mockWorkspacePermissionKeysAtom,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockGet: vi.fn(), mockGet: vi.fn(),
mockPost: vi.fn(), mockPost: vi.fn(),
mockWorkspacePermissionKeysAtom: Symbol('workspacePermissionKeysAtom'),
})) }))
vi.mock('@/service/base', () => ({ vi.mock('@/service/base', () => ({
@ -37,12 +39,17 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list',
}), }),
})) }))
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context-state', () => ({
useAppContext: () => ({ workspacePermissionKeysAtom: mockWorkspacePermissionKeysAtom,
isCurrentWorkspaceManager: true, }))
isCurrentWorkspaceOwner: false,
workspacePermissionKeys: ['plugin.install'], vi.mock('jotai', () => ({
}), useAtomValue: (atom: unknown) => {
if (atom === mockWorkspacePermissionKeysAtom)
return ['plugin.install']
throw new Error('Unexpected atom')
},
})) }))
vi.mock('../use-tools', () => ({ vi.mock('../use-tools', () => ({

View File

@ -40,12 +40,13 @@ import {
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { cloneDeep } from 'es-toolkit/object' import { cloneDeep } from 'es-toolkit/object'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { FormTypeEnum } from '@/app/components/base/form/types' import { FormTypeEnum } from '@/app/components/base/form/types'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types' import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types'
import { useAppContext } from '@/context/app-context' import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { fetchModelProviderModelList } from '@/service/common' import { fetchModelProviderModelList } from '@/service/common'
import { fetchPluginInfoFromMarketPlace, uninstallPlugin } from '@/service/plugins' import { fetchPluginInfoFromMarketPlace, uninstallPlugin } from '@/service/plugins'
import { hasPermission } from '@/utils/permission' import { hasPermission } from '@/utils/permission'
@ -1233,7 +1234,7 @@ export const useFetchPluginsInMarketPlaceByInfo = (infos: MarketplacePluginInfoR
export const usePluginTaskList = (category?: PluginCategoryEnum | string) => { export const usePluginTaskList = (category?: PluginCategoryEnum | string) => {
const initializedRef = useRef(false) const initializedRef = useRef(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { workspacePermissionKeys } = useAppContext() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManagement = hasPermission(workspacePermissionKeys, 'plugin.install') const canManagement = hasPermission(workspacePermissionKeys, 'plugin.install')
const { refreshPluginList } = useRefreshPluginList() const { refreshPluginList } = useRefreshPluginList()
const query = useQuery<PluginTaskListResponse>({ const query = useQuery<PluginTaskListResponse>({