From 58d71bf9c90ea844b2eee173982715744b95eea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Thu, 27 Aug 2026 02:27:57 +0000 Subject: [PATCH] fix(web): align model provider access permissions (#41329) --- .../__tests__/hooks.spec.ts | 20 ++++++++ .../__tests__/index.spec.tsx | 32 ++++++++++++- .../model-provider-page/hooks.ts | 12 +++-- .../model-provider-page/index.tsx | 47 ++++++++++++------- .../__tests__/index.spec.tsx | 38 ++++++++++++++- .../provider-added-card/index.tsx | 27 +++++++---- 6 files changed, 142 insertions(+), 34 deletions(-) diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts index 9dd28b12575..f8e03853fbc 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/hooks.spec.ts @@ -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, diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx index 7edf7ff90c4..f87e5ed837e 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx @@ -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 = } 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() diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index cc6255a768c..72b453c3293 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -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, } } diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx index 07cbccd0152..097f2ab0d76 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.tsx @@ -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) => ( - - ) + const showWarning = + canSetPluginPreferences && + !isLoadingModelProviders && + !isDefaultModelLoading && + !!warningTextKey + const systemModelSelector = (className: string) => { + if (!canSetPluginPreferences) return null + + return ( + + ) + } const [filteredConfiguredProviders, filteredNotConfiguredProviders] = useMemo(() => { const filteredConfiguredProviders = configuredProviders.filter( diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx index 4a08bb1105b..f93798d4196 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/index.spec.tsx @@ -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() 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( + <> + + + , + ) + + 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() + + expect(screen.getByTestId('credential-panel')).toBeInTheDocument() }) it('refreshes provider data and installed plugin details after an update', async () => { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx index fa8cf1fa4b2..2219f8d8be6 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx @@ -46,9 +46,14 @@ const ProviderAddedCard: FC = ({ 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 = ({ (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 = ({ } = 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 = ({
- {(showModelProvider || !notConfigured) && ( + {canSetPluginPreferences && (showModelProvider || !notConfigured) && (
{showCollapsedSection && (
- {(showModelProvider || !notConfigured) && ( + {canSetPluginPreferences && (showModelProvider || !notConfigured) && (