fix(web): align model provider access permissions (#41329)

This commit is contained in:
非法操作
2026-08-27 02:27:57 +00:00
committed by GitHub
parent 82520c144f
commit 58d71bf9c9
6 changed files with 142 additions and 34 deletions
@@ -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,
@@ -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(
@@ -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 () => {
@@ -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"