mirror of
https://github.com/langgenius/dify.git
synced 2026-08-28 19:23:36 +08:00
fix(web): align model provider access permissions (#41329)
This commit is contained in:
@@ -396,6 +396,26 @@ describe('hooks', () => {
|
||||
expect(result.current.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should keep the query disabled when requested', () => {
|
||||
;(useQuery as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDefaultModel(ModelTypeEnum.textEmbedding, { enabled: false }),
|
||||
)
|
||||
|
||||
expect(useQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabled: false,
|
||||
queryKey: ['default-model', ModelTypeEnum.textEmbedding],
|
||||
}),
|
||||
)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle loading state', () => {
|
||||
;(useQuery as Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
|
||||
+30
-2
@@ -10,6 +10,7 @@ import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import {
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelTypeEnum,
|
||||
QuotaUnitEnum,
|
||||
} from '../declarations'
|
||||
import ModelProviderPage from '../index'
|
||||
@@ -30,6 +31,11 @@ const { mockSetSettingsDestination, mockSaveAutoUpgrade } = vi.hoisted(() => ({
|
||||
mockSaveAutoUpgrade: vi.fn(),
|
||||
}))
|
||||
|
||||
const { mockDefaultModelQuery, mockPluginSettingsAccess } = vi.hoisted(() => ({
|
||||
mockDefaultModelQuery: vi.fn(),
|
||||
mockPluginSettingsAccess: { canSetPluginPreferences: true },
|
||||
}))
|
||||
|
||||
const { mockReferenceSetting, mockAutoUpgradeError } = vi.hoisted(() => ({
|
||||
mockReferenceSetting: {
|
||||
permission: {},
|
||||
@@ -232,7 +238,10 @@ const mockDefaultModels: Record<string, { data: unknown; isLoading: boolean }> =
|
||||
}
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
|
||||
useDefaultModel: (type: string, options?: { enabled?: boolean }) => {
|
||||
mockDefaultModelQuery(type, options)
|
||||
return mockDefaultModels[type] ?? { data: null, isLoading: false }
|
||||
},
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
@@ -282,7 +291,7 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
||||
}),
|
||||
usePluginSettingsAccess: () => ({
|
||||
canSetPermissions: true,
|
||||
canSetPluginPreferences: true,
|
||||
canSetPluginPreferences: mockPluginSettingsAccess.canSetPluginPreferences,
|
||||
}),
|
||||
default: () => ({
|
||||
referenceSetting: mockReferenceSetting,
|
||||
@@ -390,6 +399,7 @@ describe('ModelProviderPage', () => {
|
||||
mockProviderContextState.isLoadingModelProviders = false
|
||||
mockProviderContextState.isSuccessModelProviders = true
|
||||
mockProviderContextState.modelProviderPlugins = {}
|
||||
mockPluginSettingsAccess.canSetPluginPreferences = true
|
||||
mockAutoUpgradeError.value = undefined
|
||||
mockReferenceSetting.auto_upgrade = {
|
||||
strategy_setting: 'latest',
|
||||
@@ -447,6 +457,24 @@ describe('ModelProviderPage', () => {
|
||||
expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should skip system model settings without plugin preference access', () => {
|
||||
mockPluginSettingsAccess.canSetPluginPreferences = false
|
||||
|
||||
renderModelProviderPage()
|
||||
|
||||
expect(mockDefaultModelQuery.mock.calls).toEqual([
|
||||
[ModelTypeEnum.textGeneration, { enabled: false }],
|
||||
[ModelTypeEnum.textEmbedding, { enabled: false }],
|
||||
[ModelTypeEnum.rerank, { enabled: false }],
|
||||
[ModelTypeEnum.speech2text, { enabled: false }],
|
||||
[ModelTypeEnum.tts, { enabled: false }],
|
||||
])
|
||||
expect(screen.queryByTestId('system-model-selector')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /plugin\.autoUpdate\.autoUpdate/ }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should align the toolbar without extra internal top offset', () => {
|
||||
const { container } = renderModelProviderPage()
|
||||
|
||||
|
||||
@@ -75,11 +75,11 @@ export const useLanguage = () => {
|
||||
return locale.replace('-', '_')
|
||||
}
|
||||
|
||||
type UseModelListOptions = {
|
||||
type ModelQueryOptions = {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const useModelList = (type: ModelTypeEnum, { enabled = true }: UseModelListOptions = {}) => {
|
||||
export const useModelList = (type: ModelTypeEnum, { enabled = true }: ModelQueryOptions = {}) => {
|
||||
const { data, refetch, isPending } = useQuery({
|
||||
queryKey: consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({
|
||||
input: {
|
||||
@@ -99,16 +99,20 @@ export const useModelList = (type: ModelTypeEnum, { enabled = true }: UseModelLi
|
||||
}
|
||||
}
|
||||
|
||||
export const useDefaultModel = (type: ModelTypeEnum) => {
|
||||
export const useDefaultModel = (
|
||||
type: ModelTypeEnum,
|
||||
{ enabled = true }: ModelQueryOptions = {},
|
||||
) => {
|
||||
const { data, refetch, isPending } = useQuery({
|
||||
queryKey: commonQueryKeys.defaultModel(type),
|
||||
queryFn: () => fetchDefaultModal(`/workspaces/current/default-model?model_type=${type}`),
|
||||
enabled,
|
||||
})
|
||||
|
||||
return {
|
||||
data: data?.data,
|
||||
mutate: refetch,
|
||||
isLoading: isPending,
|
||||
isLoading: enabled && isPending,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,17 +53,20 @@ const ModelProviderPage = ({
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const { t } = useTranslation()
|
||||
const { canSetPluginPreferences } = usePluginSettingsAccess()
|
||||
const defaultModelQueryOptions = { enabled: canSetPluginPreferences }
|
||||
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } =
|
||||
useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
useDefaultModel(ModelTypeEnum.textGeneration, defaultModelQueryOptions)
|
||||
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } =
|
||||
useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
useDefaultModel(ModelTypeEnum.textEmbedding, defaultModelQueryOptions)
|
||||
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(
|
||||
ModelTypeEnum.rerank,
|
||||
defaultModelQueryOptions,
|
||||
)
|
||||
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } =
|
||||
useDefaultModel(ModelTypeEnum.speech2text)
|
||||
useDefaultModel(ModelTypeEnum.speech2text, defaultModelQueryOptions)
|
||||
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(
|
||||
ModelTypeEnum.tts,
|
||||
defaultModelQueryOptions,
|
||||
)
|
||||
const {
|
||||
modelProviders: providers,
|
||||
@@ -154,21 +157,29 @@ const ModelProviderPage = ({
|
||||
systemModelConfigStatus === 'no-provider' || systemModelConfigStatus === 'none-configured'
|
||||
? 'modelProvider.noneConfigured'
|
||||
: null
|
||||
const showWarning = !isLoadingModelProviders && !isDefaultModelLoading && !!warningTextKey
|
||||
const systemModelSelector = (className: string) => (
|
||||
<SystemModelSelector
|
||||
className={className}
|
||||
notConfigured={showWarning}
|
||||
textGenerationDefaultModel={textGenerationDefaultModel}
|
||||
embeddingsDefaultModel={embeddingsDefaultModel}
|
||||
rerankDefaultModel={rerankDefaultModel}
|
||||
speech2textDefaultModel={speech2textDefaultModel}
|
||||
ttsDefaultModel={ttsDefaultModel}
|
||||
isLoading={isDefaultModelLoading}
|
||||
hideProviderSettingsFooter={hideSystemModelSelectorProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
/>
|
||||
)
|
||||
const showWarning =
|
||||
canSetPluginPreferences &&
|
||||
!isLoadingModelProviders &&
|
||||
!isDefaultModelLoading &&
|
||||
!!warningTextKey
|
||||
const systemModelSelector = (className: string) => {
|
||||
if (!canSetPluginPreferences) return null
|
||||
|
||||
return (
|
||||
<SystemModelSelector
|
||||
className={className}
|
||||
notConfigured={showWarning}
|
||||
textGenerationDefaultModel={textGenerationDefaultModel}
|
||||
embeddingsDefaultModel={embeddingsDefaultModel}
|
||||
rerankDefaultModel={rerankDefaultModel}
|
||||
speech2textDefaultModel={speech2textDefaultModel}
|
||||
ttsDefaultModel={ttsDefaultModel}
|
||||
isLoading={isDefaultModelLoading}
|
||||
hideProviderSettingsFooter={hideSystemModelSelectorProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const [filteredConfiguredProviders, filteredNotConfiguredProviders] = useMemo(() => {
|
||||
const filteredConfiguredProviders = configuredProviders.filter(
|
||||
|
||||
+36
-2
@@ -12,7 +12,9 @@ import { ConfigurationMethodEnum } from '../../declarations'
|
||||
import ProviderAddedCard from '../index'
|
||||
|
||||
let mockIsCurrentWorkspaceManager = true
|
||||
let mockRbacEnabled = false
|
||||
let mockWorkspacePermissionKeys: string[] = [
|
||||
'plugin.plugin_preferences',
|
||||
'plugin.model_config',
|
||||
'credential.use',
|
||||
'credential.create',
|
||||
@@ -140,7 +142,7 @@ const createConsoleQueryClient = () =>
|
||||
|
||||
const renderWithQueryClient = (node: ReactElement) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
seedSystemFeatures(queryClient)
|
||||
seedSystemFeatures(queryClient, { rbac_enabled: mockRbacEnabled })
|
||||
return render(node, { wrapper: createQueryClientWrapper(queryClient) })
|
||||
}
|
||||
|
||||
@@ -190,7 +192,7 @@ const modelProviderModelsResponse = {
|
||||
describe('ProviderAddedCard', () => {
|
||||
const mockProvider = {
|
||||
provider: 'langgenius/openai/openai',
|
||||
configurate_methods: ['predefinedModel'],
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
system_configuration: { enabled: true },
|
||||
supported_model_types: ['llm'],
|
||||
} as unknown as ModelProvider
|
||||
@@ -198,7 +200,9 @@ describe('ProviderAddedCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
mockRbacEnabled = false
|
||||
mockWorkspacePermissionKeys = [
|
||||
'plugin.plugin_preferences',
|
||||
'plugin.model_config',
|
||||
'credential.use',
|
||||
'credential.create',
|
||||
@@ -210,6 +214,36 @@ describe('ProviderAddedCard', () => {
|
||||
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
|
||||
expect(screen.getByTestId('provider-added-card')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('provider-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('credential-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide credential controls from legacy use-only members', () => {
|
||||
mockWorkspacePermissionKeys = ['credential.use']
|
||||
|
||||
renderWithQueryClient(
|
||||
<>
|
||||
<ProviderAddedCard provider={mockProvider} />
|
||||
<ExternalExpandControls />
|
||||
</>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('credential-panel')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /modelProvider\.showModels/i }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('expand-current-provider'))
|
||||
|
||||
expect(mockFetchModelProviderModels).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use credential permissions when RBAC is enabled', () => {
|
||||
mockRbacEnabled = true
|
||||
mockWorkspacePermissionKeys = ['credential.use']
|
||||
|
||||
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
|
||||
|
||||
expect(screen.getByTestId('credential-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refreshes provider data and installed plugin details after an update', async () => {
|
||||
|
||||
+19
-8
@@ -46,9 +46,14 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
pluginSummary,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
const {
|
||||
data: { deploymentEdition, rbacEnabled },
|
||||
} = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
select: ({ deployment_edition, rbac_enabled }) => ({
|
||||
deploymentEdition: deployment_edition,
|
||||
rbacEnabled: rbac_enabled,
|
||||
}),
|
||||
})
|
||||
const language = useLanguage()
|
||||
const refreshModelProviders = useProviderContextSelector((state) => state.refreshModelProviders)
|
||||
@@ -63,6 +68,11 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
(method) => method === ConfigurationMethodEnum.customizableModel,
|
||||
)
|
||||
const systemConfig = provider.system_configuration
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canSetPluginPreferences = hasPermission(
|
||||
workspacePermissionKeys,
|
||||
'plugin.plugin_preferences',
|
||||
)
|
||||
const {
|
||||
data: modelList = [],
|
||||
isFetching: loading,
|
||||
@@ -71,21 +81,22 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
} = useQuery(
|
||||
consoleQuery.workspaces.current.modelProviders.byProvider.models.get.queryOptions({
|
||||
input: { params: { provider: currentProviderName } },
|
||||
enabled: expanded,
|
||||
enabled: expanded && canSetPluginPreferences,
|
||||
refetchOnWindowFocus: false,
|
||||
select: normalizeModelProviderModelsResponse,
|
||||
}),
|
||||
)
|
||||
const hasModelList = hasFetchedModelList && !!modelList.length
|
||||
const showCollapsedSection = !expanded || !hasFetchedModelList
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const showCollapsedSection = !canSetPluginPreferences || !expanded || !hasFetchedModelList
|
||||
const showModelProvider =
|
||||
systemConfig.enabled &&
|
||||
MODEL_PROVIDER_QUOTA_GET_PAID.includes(currentProviderName as ModelProviderQuotaGetPaid) &&
|
||||
deploymentEdition === 'CLOUD'
|
||||
const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config')
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const canAccessCredentials = canUseCredential || canCreateCredential || canManageCredential
|
||||
const canAccessCredentials = rbacEnabled
|
||||
? canUseCredential || canCreateCredential || canManageCredential
|
||||
: canManageCredential
|
||||
const showCredential = supportsPredefinedModel && canAccessCredentials
|
||||
const showCustomModelActions = supportsCustomizableModel && canConfigureModels
|
||||
|
||||
@@ -176,7 +187,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 hidden min-h-20 flex-wrap items-end gap-2 rounded-xl bg-linear-to-t from-components-panel-on-panel-item-bg via-components-panel-on-panel-item-bg to-background-gradient-mask-transparent p-4 group-focus-within:flex group-hover:flex">
|
||||
{(showModelProvider || !notConfigured) && (
|
||||
{canSetPluginPreferences && (showModelProvider || !notConfigured) && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 min-w-0 flex-1 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-xs outline-hidden hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
@@ -260,7 +271,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
</div>
|
||||
{showCollapsedSection && (
|
||||
<div className="group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pr-2.75 pl-2 system-xs-medium text-text-tertiary">
|
||||
{(showModelProvider || !notConfigured) && (
|
||||
{canSetPluginPreferences && (showModelProvider || !notConfigured) && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 items-center rounded-lg border-none bg-transparent pr-1.5 pl-1 text-left outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
|
||||
Reference in New Issue
Block a user