mirror of
https://github.com/langgenius/dify.git
synced 2026-06-23 12:31:13 +08:00
fix(tests): enhance toast mock and add preview-only app warning test (#37749)
This commit is contained in:
parent
99010dab3e
commit
76e587f78a
@ -49,7 +49,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
|
||||
workspacePermissionKeys: ['tool.manage', 'credential.manage', 'credential.use'],
|
||||
workspacePermissionKeys: ['tool.manage', 'credential.create', 'credential.manage', 'credential.use'],
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@ -34,6 +34,23 @@ let mockIsError = false
|
||||
const mockHandleImportDSL = vi.fn()
|
||||
const mockHandleImportDSLConfirm = vi.fn()
|
||||
const mockTrackCreateApp = vi.fn()
|
||||
const toastMocks = vi.hoisted(() => {
|
||||
const record = vi.fn()
|
||||
const api = Object.assign(vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })), {
|
||||
success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })),
|
||||
error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })),
|
||||
warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })),
|
||||
info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })),
|
||||
dismiss: vi.fn(),
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
})
|
||||
return { record, api }
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMocks.api,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useLearnDifyAppList: () => ({
|
||||
@ -465,6 +482,36 @@ describe('AppList', () => {
|
||||
expect(screen.getByRole('link', { name: 'explore.continueWork.exploreStudio' })).toHaveAttribute('href', '/apps')
|
||||
})
|
||||
|
||||
it('should render preview-only continue work app as a dimmed card and warn on click', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockWorkspaceApps = [
|
||||
createWorkspaceApp({
|
||||
id: 'preview-app',
|
||||
name: 'Preview Only App',
|
||||
author_name: 'Readonly Author',
|
||||
permission_keys: [AppACLPermission.Preview],
|
||||
}),
|
||||
]
|
||||
|
||||
renderAppList()
|
||||
|
||||
const card = screen.getByRole('button', { name: 'Preview Only App' })
|
||||
expect(card).toHaveClass('opacity-60')
|
||||
expect(card).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.queryByRole('link', { name: /Preview Only App/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Readonly Author')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(card)
|
||||
|
||||
expect(toastMocks.record).toHaveBeenCalledWith({
|
||||
type: 'warning',
|
||||
message: 'app.noAccessResourcePermission',
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide continue work when there are no workspace apps', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
|
||||
154
web/app/components/explore/continue-work/__tests__/item.spec.tsx
Normal file
154
web/app/components/explore/continue-work/__tests__/item.spec.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import type { AnchorHTMLAttributes, ReactNode } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import ContinueWorkItem from '../item'
|
||||
|
||||
const mockAppContext = vi.hoisted(() => ({
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: ['app.create_and_management'],
|
||||
}))
|
||||
|
||||
const mockFormatTimeFromNow = vi.hoisted(() => vi.fn(() => '5 minutes ago'))
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
warning: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: typeof mockAppContext) => unknown) => selector(mockAppContext),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: mockFormatTimeFromNow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
warning: toastMocks.warning,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & { children?: ReactNode, href: string }) => (
|
||||
<a href={href} className={className} {...props}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: 'app-1',
|
||||
name: 'Continue App',
|
||||
description: 'Continue app description',
|
||||
author_name: 'Alice',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
mode: AppModeEnum.CHAT,
|
||||
enable_site: false,
|
||||
enable_api: false,
|
||||
api_rpm: 60,
|
||||
api_rph: 3600,
|
||||
is_demo: false,
|
||||
model_config: {} as App['model_config'],
|
||||
app_model_config: {} as App['app_model_config'],
|
||||
created_at: 100,
|
||||
maintainer: 'maintainer-1',
|
||||
updated_at: 200,
|
||||
site: {} as App['site'],
|
||||
api_base_url: '',
|
||||
tags: [],
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
permission_keys: [AppACLPermission.Edit],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderItem = (
|
||||
app: App,
|
||||
systemFeatures: NonNullable<Parameters<typeof renderWithSystemFeatures>[1]>['systemFeatures'] = { rbac_enabled: true },
|
||||
) => renderWithSystemFeatures(<ContinueWorkItem app={app} />, { systemFeatures })
|
||||
|
||||
describe('ContinueWorkItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppContext.userProfile = { id: 'user-1' }
|
||||
mockAppContext.workspacePermissionKeys = ['app.create_and_management']
|
||||
mockFormatTimeFromNow.mockReturnValue('5 minutes ago')
|
||||
})
|
||||
|
||||
it('should render a link to the app configuration page when the app is editable', () => {
|
||||
renderItem(createApp())
|
||||
|
||||
const link = screen.getByRole('link', { name: /Continue App/ })
|
||||
|
||||
expect(link).toHaveAttribute('href', '/app/app-1/configuration')
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}')).toBeInTheDocument()
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000)
|
||||
})
|
||||
|
||||
it('should use created time when updated time is missing', () => {
|
||||
renderItem(createApp({ updated_at: 0, created_at: 123 }))
|
||||
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(123000)
|
||||
})
|
||||
|
||||
it('should link to access config when RBAC is enabled and only access config permission is available', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }))
|
||||
|
||||
expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute('href', '/app/app-1/access-config')
|
||||
})
|
||||
|
||||
it('should fall back to develop when RBAC is disabled for an access-config-only app', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), { rbac_enabled: false })
|
||||
|
||||
expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute('href', '/app/app-1/develop')
|
||||
})
|
||||
|
||||
it('should render preview-only apps as disabled buttons and warn on click', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.Preview] }))
|
||||
|
||||
const card = screen.getByRole('button', { name: 'Continue App' })
|
||||
|
||||
expect(card).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(card).toHaveClass('cursor-not-allowed')
|
||||
expect(card).toHaveClass('opacity-60')
|
||||
expect(screen.queryByRole('link', { name: /Continue App/ })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(card)
|
||||
|
||||
expect(toastMocks.warning).toHaveBeenCalledWith('app.noAccessResourcePermission')
|
||||
})
|
||||
|
||||
it('should warn when activating a preview-only app with Enter or Space', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.Preview] }))
|
||||
|
||||
const card = screen.getByRole('button', { name: 'Continue App' })
|
||||
|
||||
fireEvent.keyDown(card, { key: 'Enter' })
|
||||
fireEvent.keyDown(card, { key: ' ' })
|
||||
|
||||
expect(toastMocks.warning).toHaveBeenCalledTimes(2)
|
||||
expect(toastMocks.warning).toHaveBeenNthCalledWith(1, 'app.noAccessResourcePermission')
|
||||
expect(toastMocks.warning).toHaveBeenNthCalledWith(2, 'app.noAccessResourcePermission')
|
||||
})
|
||||
|
||||
it('should ignore other keys on preview-only app cards', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.Preview] }))
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Continue App' }), { key: 'Escape' })
|
||||
|
||||
expect(toastMocks.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import type { App } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -11,6 +13,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import Link from '@/next/link'
|
||||
import { getRedirectionPath } from '@/utils/app-redirection'
|
||||
import { hasOnlyAppPreviewPermission } from '@/utils/permission'
|
||||
|
||||
type ContinueWorkItemProps = {
|
||||
app: App
|
||||
@ -26,15 +29,32 @@ const ContinueWorkItem = ({
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const updatedAt = (app.updated_at || app.created_at) * 1000
|
||||
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
|
||||
const href = getRedirectionPath(app, {
|
||||
currentUserId,
|
||||
resourceMaintainer: app.maintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
})
|
||||
const cardClassName = cn(
|
||||
'flex min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-4 pb-4 shadow-xs shadow-shadow-shadow-3',
|
||||
isPreviewOnly && 'cursor-not-allowed opacity-60',
|
||||
)
|
||||
|
||||
return (
|
||||
<Link href={href} className="flex min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-4 pb-4 shadow-xs shadow-shadow-shadow-3">
|
||||
const showPreviewOnlyAccessWarning = () => {
|
||||
toast.warning(t('noAccessResourcePermission', { ns: 'app' }))
|
||||
}
|
||||
|
||||
const handlePreviewOnlyCardKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ')
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
showPreviewOnlyAccessWarning()
|
||||
}
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
<div className="relative shrink-0">
|
||||
<AppIcon
|
||||
size="large"
|
||||
@ -59,6 +79,28 @@ const ContinueWorkItem = ({
|
||||
<span className="min-w-0 truncate">{t('continueWork.editedAt', { ns: 'explore', time: formatTimeFromNow(updatedAt) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isPreviewOnly) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={app.name}
|
||||
aria-disabled="true"
|
||||
className={cardClassName}
|
||||
onClick={showPreviewOnlyAccessWarning}
|
||||
onKeyDown={handlePreviewOnlyCardKeyDown}
|
||||
>
|
||||
{cardContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={cardClassName}>
|
||||
{cardContent}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import { useInvalidDataSourceList } from '@/service/use-pipeline'
|
||||
import Card from '../card'
|
||||
import { useDataSourceAuthUpdate } from '../hooks'
|
||||
|
||||
let mockWorkspacePermissionKeys: string[] = ['credential.manage', 'credential.use']
|
||||
let mockWorkspacePermissionKeys: string[] = ['credential.use', 'credential.create', 'credential.manage']
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
|
||||
@ -126,7 +126,7 @@ describe('Card Component', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
mockPluginAuthActionReturn = createMockPluginAuthActionReturn()
|
||||
|
||||
vi.mocked(useDataSourceAuthUpdate).mockReturnValue({ handleAuthUpdate: mockHandleAuthUpdate })
|
||||
@ -451,7 +451,7 @@ describe('Card Component', () => {
|
||||
expectAuthUpdated()
|
||||
})
|
||||
|
||||
it('should disable configure credential actions when user lacks credential.manage', () => {
|
||||
it('should disable configure credential actions when user lacks credential.create', () => {
|
||||
// Arrange
|
||||
mockWorkspacePermissionKeys = ['credential.use']
|
||||
const configurableItem: DataSourceAuth = {
|
||||
|
||||
@ -50,7 +50,7 @@ const Card = ({
|
||||
onPluginUpdate,
|
||||
}: CardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { canUseCredential, canManageCredential } = useCredentialPermissions()
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const {
|
||||
icon,
|
||||
@ -178,7 +178,7 @@ const Card = ({
|
||||
pluginPayload={pluginPayload}
|
||||
item={item}
|
||||
onUpdate={handleAuthUpdate}
|
||||
disabled={disabled || !canManageCredential}
|
||||
disabled={disabled || !canCreateCredential}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,9 +3,13 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ConfigurationMethodEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import AddCredentialInLoadBalancing from '../add-credential-in-load-balancing'
|
||||
|
||||
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
|
||||
value: ['credential.use', 'credential.create', 'credential.manage'],
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
|
||||
workspacePermissionKeys: ['credential.use', 'credential.manage'],
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -15,13 +19,21 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth'
|
||||
authParams,
|
||||
items,
|
||||
onItemClick,
|
||||
hideAddAction,
|
||||
triggerOnlyOpenModal,
|
||||
}: {
|
||||
renderTrigger: (open?: boolean) => React.ReactNode
|
||||
authParams?: { onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void }
|
||||
items: Array<{ credentials: Array<{ credential_id: string, credential_name: string }> }>
|
||||
onItemClick?: (credential: { credential_id: string, credential_name: string }) => void
|
||||
hideAddAction?: boolean
|
||||
triggerOnlyOpenModal?: boolean
|
||||
}) => (
|
||||
<div>
|
||||
<div
|
||||
data-testid="authorized-mock"
|
||||
data-hide-add-action={String(!!hideAddAction)}
|
||||
data-trigger-only-open-modal={String(!!triggerOnlyOpenModal)}
|
||||
>
|
||||
{renderTrigger(false)}
|
||||
<button onClick={() => authParams?.onUpdate?.({ provider: 'x' }, { key: 'value' })}>Run update</button>
|
||||
<button onClick={() => onItemClick?.(items[0]!.credentials[0]!)}>Select first</button>
|
||||
@ -50,6 +62,7 @@ describe('AddCredentialInLoadBalancing', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspacePermissionKeys.value = ['credential.use', 'credential.create', 'credential.manage']
|
||||
})
|
||||
|
||||
it('should render add credential label', () => {
|
||||
@ -103,6 +116,45 @@ describe('AddCredentialInLoadBalancing', () => {
|
||||
expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0])
|
||||
})
|
||||
|
||||
it('should render credential menu for manage-only users with existing credentials', () => {
|
||||
mockWorkspacePermissionKeys.value = ['credential.manage']
|
||||
|
||||
render(
|
||||
<AddCredentialInLoadBalancing
|
||||
provider={provider}
|
||||
model={model}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
modelCredential={modelCredential}
|
||||
onSelectCredential={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/modelProvider.auth.addCredential/i))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-hide-add-action', 'true')
|
||||
expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-trigger-only-open-modal', 'false')
|
||||
})
|
||||
|
||||
it('should render nothing for manage-only users without existing credentials', () => {
|
||||
mockWorkspacePermissionKeys.value = ['credential.manage']
|
||||
|
||||
const emptyModelCredential = {
|
||||
...modelCredential,
|
||||
available_credentials: [],
|
||||
} as ModelCredential
|
||||
|
||||
const { container } = render(
|
||||
<AddCredentialInLoadBalancing
|
||||
provider={provider}
|
||||
model={model}
|
||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||
modelCredential={emptyModelCredential}
|
||||
onSelectCredential={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
// renderTrigger with open=true: bg-state-base-hover style applied
|
||||
it('should apply hover background when trigger is rendered with open=true', async () => {
|
||||
vi.doMock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
|
||||
|
||||
@ -24,9 +24,13 @@ vi.mock('../hooks/use-custom-models', () => ({
|
||||
useCanAddedModels: () => mockCanAddedModels,
|
||||
}))
|
||||
|
||||
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
|
||||
value: ['credential.use', 'credential.create', 'credential.manage'],
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
|
||||
workspacePermissionKeys: ['credential.manage', 'credential.use'],
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -60,6 +64,7 @@ describe('AddCustomModel', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspacePermissionKeys.value = ['credential.use', 'credential.create', 'credential.manage']
|
||||
mockCanAddedModels = []
|
||||
})
|
||||
|
||||
@ -120,6 +125,31 @@ describe('AddCustomModel', () => {
|
||||
expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model)
|
||||
})
|
||||
|
||||
it('should show existing model rows as disabled for create-only users', () => {
|
||||
const model = { model: 'gpt-4', model_type: 'llm' }
|
||||
mockWorkspacePermissionKeys.value = ['credential.create']
|
||||
mockCanAddedModels = [model]
|
||||
|
||||
render(
|
||||
<AddCustomModel
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
const modelRow = screen.getByText('gpt-4').closest('[aria-disabled]')
|
||||
expect(modelRow).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(modelRow).toHaveClass('cursor-not-allowed')
|
||||
|
||||
fireEvent.click(screen.getByText('gpt-4'))
|
||||
expect(mockHandleOpenModalForAddCustomModelToModelList).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/))
|
||||
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleOpenModalForAddNewCustomModel when clicking "Add New Model" in list', () => {
|
||||
mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }]
|
||||
render(
|
||||
|
||||
@ -4,17 +4,40 @@ import userEvent from '@testing-library/user-event'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import SwitchCredentialInLoadBalancing from '../switch-credential-in-load-balancing'
|
||||
|
||||
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
|
||||
value: ['credential.use', 'credential.create', 'credential.manage'],
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
|
||||
workspacePermissionKeys: ['credential.use', 'credential.manage'],
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock components
|
||||
vi.mock('../authorized', () => ({
|
||||
default: ({ renderTrigger, onItemClick, items }: { renderTrigger: () => React.ReactNode, onItemClick: (c: unknown) => void, items: { credentials: unknown[] }[] }) => (
|
||||
<div data-testid="authorized-mock">
|
||||
<div data-testid="trigger-container" onClick={() => onItemClick(items[0]!.credentials[0])}>
|
||||
default: ({
|
||||
renderTrigger,
|
||||
onItemClick,
|
||||
items,
|
||||
disabled,
|
||||
hideAddAction,
|
||||
triggerOnlyOpenModal,
|
||||
}: {
|
||||
renderTrigger: () => React.ReactNode
|
||||
onItemClick?: (c: unknown) => void
|
||||
items: { credentials: unknown[] }[]
|
||||
disabled?: boolean
|
||||
hideAddAction?: boolean
|
||||
triggerOnlyOpenModal?: boolean
|
||||
}) => (
|
||||
<div
|
||||
data-testid="authorized-mock"
|
||||
data-disabled={String(!!disabled)}
|
||||
data-hide-add-action={String(!!hideAddAction)}
|
||||
data-trigger-only-open-modal={String(!!triggerOnlyOpenModal)}
|
||||
>
|
||||
<div data-testid="trigger-container" onClick={() => onItemClick?.(items[0]!.credentials[0])}>
|
||||
{renderTrigger()}
|
||||
</div>
|
||||
</div>
|
||||
@ -50,6 +73,7 @@ describe('SwitchCredentialInLoadBalancing', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspacePermissionKeys.value = ['credential.use', 'credential.create', 'credential.manage']
|
||||
})
|
||||
|
||||
it('should render selected credential name correctly', () => {
|
||||
@ -82,7 +106,7 @@ describe('SwitchCredentialInLoadBalancing', () => {
|
||||
expect(screen.getByTestId('indicator-error'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unavailable status when credentials list is empty', () => {
|
||||
it('should render add credential status when credentials list is empty and create is allowed', () => {
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
provider={mockProvider}
|
||||
@ -93,7 +117,7 @@ describe('SwitchCredentialInLoadBalancing', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/auth.credentialUnavailableInButton/))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/modelProvider.auth.addCredential/))!.toBeInTheDocument()
|
||||
expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -112,6 +136,27 @@ describe('SwitchCredentialInLoadBalancing', () => {
|
||||
expect(mockSetCustomModelCredential).toHaveBeenCalledWith(mockCredentials[0])
|
||||
})
|
||||
|
||||
it('should keep credential menu available for manage-only users without allowing selection', () => {
|
||||
mockWorkspacePermissionKeys.value = ['credential.manage']
|
||||
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
provider={mockProvider}
|
||||
model={mockModel}
|
||||
credentials={mockCredentials}
|
||||
customModelCredential={mockCredentials[0]}
|
||||
setCustomModelCredential={mockSetCustomModelCredential}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-disabled', 'false')
|
||||
expect(screen.getByTestId('authorized-mock')).toHaveAttribute('data-hide-add-action', 'true')
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger-container'))
|
||||
|
||||
expect(mockSetCustomModelCredential).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show tooltip when empty and custom credentials not allowed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
|
||||
@ -129,8 +174,8 @@ describe('SwitchCredentialInLoadBalancing', () => {
|
||||
expect(await screen.findByText('plugin.auth.credentialUnavailable'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Empty credentials with allowed custom: no tooltip but still shows unavailable text
|
||||
it('should show unavailable status without tooltip when custom credentials are allowed', () => {
|
||||
// Empty credentials with allowed custom: no tooltip but still shows add credential text
|
||||
it('should show add credential status without tooltip when custom credentials are allowed', () => {
|
||||
// Act
|
||||
render(
|
||||
<SwitchCredentialInLoadBalancing
|
||||
@ -144,7 +189,7 @@ describe('SwitchCredentialInLoadBalancing', () => {
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
expect(screen.getByText(/auth.credentialUnavailableInButton/))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/modelProvider.auth.addCredential/))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -231,9 +276,8 @@ describe('SwitchCredentialInLoadBalancing', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// credentials is undefined → empty=true → unavailable text shown
|
||||
// credentials is undefined → empty=true → unavailable text shown
|
||||
expect(screen.getByText(/auth.credentialUnavailableInButton/))!.toBeInTheDocument()
|
||||
// credentials is undefined -> empty=true -> add credential text shown when creation is allowed.
|
||||
expect(screen.getByText(/modelProvider.auth.addCredential/))!.toBeInTheDocument()
|
||||
expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -13,8 +13,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { Authorized } from '@/app/components/header/account-setting/model-provider-page/model-auth'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
|
||||
type AddCredentialInLoadBalancingProps = {
|
||||
provider: ModelProvider
|
||||
@ -36,12 +35,11 @@ const AddCredentialInLoadBalancing = ({
|
||||
onRemove,
|
||||
}: AddCredentialInLoadBalancingProps) => {
|
||||
const { t } = useTranslation()
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage'])
|
||||
const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage')
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const {
|
||||
available_credentials,
|
||||
} = modelCredential
|
||||
const canOpenCredentialMenu = canUseCredential || canCreateCredential || (canManageCredential && !!available_credentials?.length)
|
||||
const isCustomModel = configurationMethod === ConfigurationMethodEnum.customizableModel
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
const handleUpdate = useCallback((payload?: unknown, formValues?: Record<string, unknown>) => {
|
||||
@ -63,7 +61,7 @@ const AddCredentialInLoadBalancing = ({
|
||||
return Item
|
||||
}, [t])
|
||||
|
||||
if (!canUseCredential)
|
||||
if (!canOpenCredentialMenu)
|
||||
return null
|
||||
|
||||
return (
|
||||
@ -76,7 +74,7 @@ const AddCredentialInLoadBalancing = ({
|
||||
onUpdate: handleUpdate,
|
||||
onRemove,
|
||||
}}
|
||||
triggerOnlyOpenModal={!available_credentials?.length && !notAllowCustomCredential && canManageCredential}
|
||||
triggerOnlyOpenModal={!available_credentials?.length && !notAllowCustomCredential && canCreateCredential}
|
||||
items={[
|
||||
{
|
||||
title: isCustomModel ? '' : t('modelProvider.auth.apiKeys', { ns: 'common' }),
|
||||
@ -93,7 +91,7 @@ const AddCredentialInLoadBalancing = ({
|
||||
}
|
||||
: undefined}
|
||||
onItemClick={onSelectCredential}
|
||||
hideAddAction={!canManageCredential}
|
||||
hideAddAction={!canCreateCredential}
|
||||
placement="bottom-start"
|
||||
popupTitle={isCustomModel ? t('modelProvider.auth.modelCredentials', { ns: 'common' }) : ''}
|
||||
/>
|
||||
|
||||
@ -24,8 +24,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import ModelIcon from '../model-icon'
|
||||
import { useAuth } from './hooks/use-auth'
|
||||
import { useCanAddedModels } from './hooks/use-custom-models'
|
||||
@ -46,9 +45,7 @@ const AddCustomModel = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
const canAddedModels = useCanAddedModels(provider)
|
||||
const noModels = !canAddedModels.length
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage'])
|
||||
const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage')
|
||||
const { canUseCredential, canCreateCredential } = useCredentialPermissions()
|
||||
const {
|
||||
handleOpenModal: handleOpenModalForAddNewCustomModel,
|
||||
} = useAuth(
|
||||
@ -73,7 +70,9 @@ const AddCustomModel = ({
|
||||
)
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
const renderTrigger = useCallback((open?: boolean, onClick?: () => void) => {
|
||||
const disabled = (noModels && !canManageCredential) || (!noModels && !canUseCredential)
|
||||
const disabled = noModels
|
||||
? !canCreateCredential
|
||||
: !canUseCredential && !canCreateCredential
|
||||
const item = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -99,10 +98,10 @@ const AddCustomModel = ({
|
||||
)
|
||||
}
|
||||
return item
|
||||
}, [canManageCredential, canUseCredential, t, notAllowCustomCredential, noModels])
|
||||
}, [canCreateCredential, canUseCredential, t, notAllowCustomCredential, noModels])
|
||||
|
||||
if (noModels) {
|
||||
return renderTrigger(false, notAllowCustomCredential || !canManageCredential ? undefined : handleOpenModalForAddNewCustomModel)
|
||||
return renderTrigger(false, notAllowCustomCredential || !canCreateCredential ? undefined : handleOpenModalForAddNewCustomModel)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -125,7 +124,11 @@ const AddCustomModel = ({
|
||||
canAddedModels.map(model => (
|
||||
<div
|
||||
key={model.model}
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-2',
|
||||
canUseCredential ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
aria-disabled={!canUseCredential}
|
||||
onClick={() => {
|
||||
if (!canUseCredential)
|
||||
return
|
||||
@ -151,7 +154,7 @@ const AddCustomModel = ({
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!notAllowCustomCredential && canManageCredential && (
|
||||
!notAllowCustomCredential && canCreateCredential && (
|
||||
<div
|
||||
className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 system-xs-medium text-text-accent-light-mode-only"
|
||||
onClick={() => {
|
||||
|
||||
@ -11,7 +11,7 @@ const mockHandleConfirmDelete = vi.fn()
|
||||
|
||||
let mockDeleteCredentialId: string | null = null
|
||||
let mockDoingAction = false
|
||||
let mockWorkspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
let mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
|
||||
@ -89,7 +89,7 @@ describe('Authorized', () => {
|
||||
vi.clearAllMocks()
|
||||
mockDeleteCredentialId = null
|
||||
mockDoingAction = false
|
||||
mockWorkspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
})
|
||||
|
||||
it('should render trigger and open popup when trigger is clicked', () => {
|
||||
@ -219,6 +219,55 @@ describe('Authorized', () => {
|
||||
expect(screen.queryByRole('button', { name: /addApiKey/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow create-only users to add credentials but not switch, edit, or delete existing credentials', () => {
|
||||
mockWorkspacePermissionKeys = ['credential.create']
|
||||
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]!)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]!)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0]!)
|
||||
fireEvent.click(screen.getByRole('button', { name: /addApiKey/i }))
|
||||
|
||||
expect(mockHandleActiveCredential).not.toHaveBeenCalled()
|
||||
expect(mockOpenConfirmDelete).not.toHaveBeenCalled()
|
||||
expect(mockHandleOpenModal).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, undefined)
|
||||
})
|
||||
|
||||
it('should allow manage-only users to edit and delete credentials but not switch or add them', () => {
|
||||
mockWorkspacePermissionKeys = ['credential.manage']
|
||||
|
||||
render(
|
||||
<Authorized
|
||||
provider={mockProvider}
|
||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||
items={mockItems}
|
||||
renderTrigger={mockRenderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]!)
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]!)
|
||||
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0]!)
|
||||
|
||||
expect(mockHandleOpenModal).toHaveBeenCalledWith(mockCredentials[0], mockItems[0]!.model)
|
||||
expect(mockOpenConfirmDelete).toHaveBeenCalledWith(mockCredentials[0], mockItems[0]!.model)
|
||||
expect(mockHandleActiveCredential).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: /addApiKey/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show confirm dialog and call confirm handler when delete is confirmed', () => {
|
||||
mockDeleteCredentialId = 'cred-1'
|
||||
|
||||
|
||||
@ -30,8 +30,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import { useAuth } from '../hooks'
|
||||
import AuthorizedItem from './authorized-item'
|
||||
|
||||
@ -101,9 +100,7 @@ const Authorized = ({
|
||||
disableDeleteTip,
|
||||
}: AuthorizedProps) => {
|
||||
const { t } = useTranslation()
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage'])
|
||||
const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage')
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(false)
|
||||
const mergedIsOpen = isOpen ?? isLocalOpen
|
||||
const setMergedIsOpen = useCallback((open: boolean) => {
|
||||
@ -139,12 +136,12 @@ const Authorized = ({
|
||||
)
|
||||
|
||||
const handleEdit = useCallback((credential?: Credential, model?: CustomModel) => {
|
||||
if (!canManageCredential)
|
||||
if (credential ? !canManageCredential : !canCreateCredential)
|
||||
return
|
||||
|
||||
setMergedIsOpen(false)
|
||||
handleOpenModal(credential, model)
|
||||
}, [canManageCredential, handleOpenModal, setMergedIsOpen])
|
||||
}, [canCreateCredential, canManageCredential, handleOpenModal, setMergedIsOpen])
|
||||
const handleDelete = useCallback((credential?: Credential, model?: CustomModel) => {
|
||||
if (!canManageCredential)
|
||||
return
|
||||
@ -179,11 +176,11 @@ const Authorized = ({
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
if (!canManageCredential)
|
||||
if (!canCreateCredential)
|
||||
return
|
||||
|
||||
handleOpenModal()
|
||||
}, [canManageCredential, handleOpenModal, triggerOnlyOpenModal])
|
||||
}, [canCreateCredential, handleOpenModal, triggerOnlyOpenModal])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -224,7 +221,7 @@ const Authorized = ({
|
||||
title={item.title}
|
||||
model={item.model}
|
||||
credentials={item.credentials}
|
||||
disabled={disabled || !canUseCredential}
|
||||
disabled={disabled}
|
||||
disableEdit={!canManageCredential}
|
||||
disableDelete={!canManageCredential}
|
||||
onDelete={handleDelete}
|
||||
@ -233,7 +230,7 @@ const Authorized = ({
|
||||
onEdit={handleEdit}
|
||||
showItemSelectedIcon={showItemSelectedIcon}
|
||||
selectedCredentialId={item.selectedCredential?.credential_id}
|
||||
onItemClick={handleItemClick}
|
||||
onItemClick={canUseCredential ? handleItemClick : undefined}
|
||||
showModelTitle={showModelTitle}
|
||||
/>
|
||||
{
|
||||
@ -247,7 +244,7 @@ const Authorized = ({
|
||||
</div>
|
||||
<div className="h-px bg-divider-subtle"></div>
|
||||
{
|
||||
isModelCredential && !notAllowCustomCredential && !hideAddAction && canManageCredential && (
|
||||
isModelCredential && !notAllowCustomCredential && !hideAddAction && canCreateCredential && (
|
||||
<div
|
||||
onClick={() => handleEdit(
|
||||
undefined,
|
||||
@ -266,7 +263,7 @@ const Authorized = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
!isModelCredential && !notAllowCustomCredential && !hideAddAction && canManageCredential && (
|
||||
!isModelCredential && !notAllowCustomCredential && !hideAddAction && canCreateCredential && (
|
||||
<div className="p-2">
|
||||
<Button
|
||||
onClick={() => handleEdit()}
|
||||
|
||||
@ -16,8 +16,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import Authorized from './authorized'
|
||||
|
||||
type SwitchCredentialInLoadBalancingProps = {
|
||||
@ -40,9 +39,8 @@ const SwitchCredentialInLoadBalancing = ({
|
||||
}: SwitchCredentialInLoadBalancingProps) => {
|
||||
const { t } = useTranslation()
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage'])
|
||||
const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage')
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const canOpenCredentialMenu = canUseCredential || canCreateCredential || canManageCredential
|
||||
const handleItemClick = useCallback((credential: Credential) => {
|
||||
if (!canUseCredential)
|
||||
return
|
||||
@ -67,7 +65,7 @@ const SwitchCredentialInLoadBalancing = ({
|
||||
className={cn(
|
||||
'shrink-0 space-x-1',
|
||||
(authRemoved || unavailable) && 'text-components-button-destructive-secondary-text',
|
||||
(empty || !canUseCredential) && 'cursor-not-allowed opacity-50',
|
||||
(!canOpenCredentialMenu || (empty && !canCreateCredential)) && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
{
|
||||
@ -82,7 +80,13 @@ const SwitchCredentialInLoadBalancing = ({
|
||||
authRemoved && t('modelProvider.auth.authRemoved', { ns: 'common' })
|
||||
}
|
||||
{
|
||||
(unavailable || empty) && t('auth.credentialUnavailableInButton', { ns: 'plugin' })
|
||||
unavailable && t('auth.credentialUnavailableInButton', { ns: 'plugin' })
|
||||
}
|
||||
{
|
||||
empty && canCreateCredential && !notAllowCustomCredential && t('modelProvider.auth.addCredential', { ns: 'common' })
|
||||
}
|
||||
{
|
||||
empty && (!canCreateCredential || notAllowCustomCredential) && t('auth.credentialUnavailableInButton', { ns: 'plugin' })
|
||||
}
|
||||
{
|
||||
!authRemoved && !unavailable && !empty && customModelCredential?.credential_name
|
||||
@ -95,7 +99,7 @@ const SwitchCredentialInLoadBalancing = ({
|
||||
<span className="i-ri-arrow-down-s-line size-4" />
|
||||
</Button>
|
||||
)
|
||||
if ((empty && notAllowCustomCredential) || !canUseCredential) {
|
||||
if ((empty && notAllowCustomCredential) || !canOpenCredentialMenu) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={Item} />
|
||||
@ -106,7 +110,7 @@ const SwitchCredentialInLoadBalancing = ({
|
||||
)
|
||||
}
|
||||
return Item
|
||||
}, [canUseCredential, customModelCredential, t, credentials, notAllowCustomCredential])
|
||||
}, [canCreateCredential, canOpenCredentialMenu, customModelCredential, t, credentials, notAllowCustomCredential])
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
@ -140,10 +144,9 @@ const SwitchCredentialInLoadBalancing = ({
|
||||
onItemClick={handleItemClick}
|
||||
enableAddModelCredential
|
||||
showItemSelectedIcon
|
||||
disabled={!canUseCredential}
|
||||
hideAddAction={!canManageCredential}
|
||||
hideAddAction={!canCreateCredential}
|
||||
popupTitle={t('modelProvider.auth.modelCredentials', { ns: 'common' })}
|
||||
triggerOnlyOpenModal={!credentials?.length && canManageCredential}
|
||||
triggerOnlyOpenModal={!credentials?.length && canCreateCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -79,7 +79,7 @@ vi.mock('../../model-auth/hooks', () => ({
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) =>
|
||||
selector({ workspacePermissionKeys: ['credential.manage', 'credential.use'] }),
|
||||
selector({ workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
|
||||
@ -29,7 +29,7 @@ const mockState = vi.hoisted(() => ({
|
||||
credentialData: { credentials: {}, available_credentials: [] } as CredentialData,
|
||||
doingAction: false,
|
||||
deleteCredentialId: null as string | null,
|
||||
workspacePermissionKeys: ['credential.manage', 'credential.use'] as string[],
|
||||
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[],
|
||||
formSchemas: [] as CredentialFormSchema[],
|
||||
formValues: {} as Record<string, unknown>,
|
||||
modelNameAndTypeFormSchemas: [] as CredentialFormSchema[],
|
||||
@ -184,7 +184,7 @@ describe('ModelModal', () => {
|
||||
mockState.credentialData = { credentials: {}, available_credentials: [] }
|
||||
mockState.doingAction = false
|
||||
mockState.deleteCredentialId = null
|
||||
mockState.workspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
mockState.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
mockState.formSchemas = []
|
||||
mockState.formValues = {}
|
||||
mockState.modelNameAndTypeFormSchemas = []
|
||||
|
||||
@ -41,9 +41,8 @@ import {
|
||||
useCredentialData,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
|
||||
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
FormTypeEnum,
|
||||
@ -107,9 +106,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
available_credentials,
|
||||
} = credentialData as any
|
||||
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage'])
|
||||
const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage')
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const {
|
||||
@ -135,7 +132,8 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (!canManageCredential)
|
||||
const canSubmitCredentialForm = credential ? canManageCredential : canCreateCredential
|
||||
if (!canSubmitCredentialForm)
|
||||
return
|
||||
|
||||
let modelNameAndTypeIsCheckValidated = true
|
||||
@ -197,7 +195,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
})
|
||||
}
|
||||
onSave(values)
|
||||
}, [mode, selectedCredential, model, canUseCredential, canManageCredential, onSave, handleActiveCredential, onCancel, handleSaveCredential, credential?.credential_id])
|
||||
}, [mode, selectedCredential, model, canUseCredential, canCreateCredential, canManageCredential, onSave, handleActiveCredential, onCancel, handleSaveCredential, credential])
|
||||
|
||||
const modalTitle = useMemo(() => {
|
||||
let label = t('modelProvider.auth.apiKeyModal.title', { ns: 'common' })
|
||||
@ -277,7 +275,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
}, [mode, t])
|
||||
const canSaveCredentialChange = mode === ModelModalModeEnum.addCustomModelToModelList && selectedCredential && !selectedCredential.addNewCredential
|
||||
? canUseCredential
|
||||
: canManageCredential
|
||||
: credential ? canManageCredential : canCreateCredential
|
||||
|
||||
const handleDeleteCredential = useCallback(() => {
|
||||
handleConfirmDelete()
|
||||
@ -339,7 +337,7 @@ const ModelModal: FC<ModelModalProps> = ({
|
||||
onSelect={setSelectedCredential}
|
||||
selectedCredential={selectedCredential}
|
||||
disabled={isLoading}
|
||||
notAllowAddNewCredential={notAllowCustomCredential || !canManageCredential}
|
||||
notAllowAddNewCredential={notAllowCustomCredential || !canCreateCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -72,10 +72,13 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}))
|
||||
|
||||
const mockUseAppContext = vi.hoisted(() => vi.fn())
|
||||
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
|
||||
value: ['credential.use', 'credential.create', 'credential.manage'],
|
||||
}))
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: mockUseAppContext,
|
||||
useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({
|
||||
workspacePermissionKeys: ['credential.manage', 'credential.use'],
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -136,6 +139,7 @@ const renderWithCombobox = (
|
||||
describe('PopupItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspacePermissionKeys.value = ['credential.use', 'credential.create', 'credential.manage']
|
||||
mockUseLanguage.mockReturnValue('en_US')
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [makeProvider()],
|
||||
@ -412,4 +416,18 @@ describe('PopupItem', () => {
|
||||
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep the credential dropdown enabled for manage-only users', () => {
|
||||
mockWorkspacePermissionKeys.value = ['credential.manage']
|
||||
|
||||
renderWithCombobox(<PopupItem {...previewCardProps()} model={makeModel()} onHide={vi.fn()} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /my-api-key/ })
|
||||
|
||||
expect(trigger).not.toBeDisabled()
|
||||
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'close dropdown' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,10 +8,9 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import { ConfigurationMethodEnum, ModelStatusEnum } from '../declarations'
|
||||
import { useLanguage, useUpdateModelList, useUpdateModelProviders } from '../hooks'
|
||||
import ModelIcon from '../model-icon'
|
||||
@ -50,11 +49,10 @@ function PopupItem({
|
||||
const updateModelList = useUpdateModelList()
|
||||
const updateModelProviders = useUpdateModelProviders()
|
||||
const currentProvider = modelProviders.find(provider => provider.provider === model.provider)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canUseCredentials = hasPermission(workspacePermissionKeys, ['credential.manage', 'credential.use'])
|
||||
const canManageCredentials = hasPermission(workspacePermissionKeys, 'credential.manage')
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const canOpenCredentialDropdown = canUseCredential || canCreateCredential || canManageCredential
|
||||
const handleOpenModelModal = () => {
|
||||
if (!canManageCredentials)
|
||||
if (!canCreateCredential)
|
||||
return
|
||||
|
||||
if (!currentProvider)
|
||||
@ -110,7 +108,7 @@ function PopupItem({
|
||||
</button>
|
||||
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={!canUseCredentials}
|
||||
disabled={!canOpenCredentialDropdown}
|
||||
render={(
|
||||
<button type="button" className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover">
|
||||
{isUsingCredits
|
||||
@ -142,7 +140,7 @@ function PopupItem({
|
||||
<span className="ml-1 truncate text-text-tertiary">{t('modelProvider.selector.configureRequired', { ns: 'common' })}</span>
|
||||
</>
|
||||
)}
|
||||
{canUseCredentials && <span className="i-ri-arrow-down-s-line size-3.5! shrink-0 translate-y-px text-text-tertiary" />}
|
||||
{canOpenCredentialDropdown && <span className="i-ri-arrow-down-s-line size-3.5! shrink-0 translate-y-px text-text-tertiary" />}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
@ -193,7 +191,7 @@ function PopupItem({
|
||||
onPointerDown={onPreviewCardClose}
|
||||
>
|
||||
{rowContent}
|
||||
{canManageCredentials && (
|
||||
{canCreateCredential && (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block"
|
||||
|
||||
@ -8,7 +8,7 @@ import { ConfigurationMethodEnum } from '../../declarations'
|
||||
import ProviderAddedCard from '../index'
|
||||
|
||||
let mockIsCurrentWorkspaceManager = true
|
||||
let mockWorkspacePermissionKeys: string[] = ['plugin.manage', 'credential.manage', 'credential.use']
|
||||
let mockWorkspacePermissionKeys: string[] = ['plugin.manage', 'credential.use', 'credential.create', 'credential.manage']
|
||||
const mockFetchModelProviderModels = vi.fn()
|
||||
const mockQueryOptions = vi.fn(({ input, ...options }: { input: { params: { provider: string } }, enabled?: boolean }) => ({
|
||||
queryKey: ['console', 'modelProviders', 'models', input.params.provider],
|
||||
@ -105,7 +105,7 @@ describe('ProviderAddedCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
mockWorkspacePermissionKeys = ['plugin.manage', 'credential.manage', 'credential.use']
|
||||
mockWorkspacePermissionKeys = ['plugin.manage', 'credential.use', 'credential.create', 'credential.manage']
|
||||
})
|
||||
|
||||
it('should render provider added card component', () => {
|
||||
@ -213,7 +213,7 @@ describe('ProviderAddedCard', () => {
|
||||
|
||||
unmount()
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
mockWorkspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
|
||||
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
@ -69,8 +70,9 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(currentProviderName as ModelProviderQuotaGetPaid) && !IS_CE_EDITION
|
||||
const canManagePlugins = hasPermission(workspacePermissionKeys, 'plugin.manage')
|
||||
const canUseCredentials = hasPermission(workspacePermissionKeys, ['credential.manage', 'credential.use'])
|
||||
const showCredential = supportsPredefinedModel && canUseCredentials
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const canAccessCredentials = canUseCredential || canCreateCredential || canManageCredential
|
||||
const showCredential = supportsPredefinedModel && canAccessCredentials
|
||||
const showCustomModelActions = supportsCustomizableModel && canManagePlugins
|
||||
|
||||
const refreshModelList = useCallback((targetProviderName: string) => {
|
||||
|
||||
@ -5,7 +5,7 @@ import ApiKeySection from '../api-key-section'
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({
|
||||
workspacePermissionKeys: ['credential.manage', 'credential.use'],
|
||||
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'],
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ const mockOpenConfirmDelete = vi.fn()
|
||||
const mockCloseConfirmDelete = vi.fn()
|
||||
const mockHandleConfirmDelete = vi.fn()
|
||||
let mockDeleteCredentialId: string | null = null
|
||||
let mockWorkspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
let mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
|
||||
vi.mock('../../use-trial-credits', () => ({
|
||||
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
|
||||
@ -53,7 +53,7 @@ vi.mock('../../../model-auth/authorized/credential-item', () => ({
|
||||
}) => (
|
||||
<div data-testid={`credential-${credential.credential_id}`}>
|
||||
<span>{credential.credential_name}</span>
|
||||
<button data-testid={`click-${credential.credential_id}`} onClick={() => onItemClick?.(credential)}>select</button>
|
||||
<button data-testid={`click-${credential.credential_id}`} disabled={disabled} onClick={() => onItemClick?.(credential)}>select</button>
|
||||
<button data-testid={`edit-${credential.credential_id}`} disabled={disabled || disableEdit} onClick={() => onEdit?.(credential)}>edit</button>
|
||||
<button data-testid={`delete-${credential.credential_id}`} disabled={disabled || disableDelete} onClick={() => onDelete?.(credential)}>delete</button>
|
||||
</div>
|
||||
@ -97,7 +97,7 @@ describe('DropdownContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDeleteCredentialId = null
|
||||
mockWorkspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
})
|
||||
|
||||
describe('UsagePrioritySection visibility', () => {
|
||||
@ -397,6 +397,58 @@ describe('DropdownContent', () => {
|
||||
expect(mockHandleOpenModal).not.toHaveBeenCalled()
|
||||
expect(mockOpenConfirmDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow create-only users to add credentials but not switch, edit, or delete existing credentials', () => {
|
||||
mockWorkspacePermissionKeys = ['credential.create']
|
||||
|
||||
render(
|
||||
<DropdownContent
|
||||
provider={createProvider()}
|
||||
state={createState()}
|
||||
isChangingPriority={false}
|
||||
onChangePriority={onChangePriority}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('click-cred-2'))
|
||||
fireEvent.click(screen.getByTestId('edit-cred-2'))
|
||||
fireEvent.click(screen.getByTestId('delete-cred-2'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /addApiKey/ }))
|
||||
|
||||
expect(mockActivate).not.toHaveBeenCalled()
|
||||
expect(mockOpenConfirmDelete).not.toHaveBeenCalled()
|
||||
expect(mockHandleOpenModal).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleOpenModal).toHaveBeenCalledWith()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should allow manage-only users to edit and delete credentials but not switch or add them', () => {
|
||||
mockWorkspacePermissionKeys = ['credential.manage']
|
||||
|
||||
render(
|
||||
<DropdownContent
|
||||
provider={createProvider()}
|
||||
state={createState()}
|
||||
isChangingPriority={false}
|
||||
onChangePriority={onChangePriority}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('click-cred-2'))
|
||||
fireEvent.click(screen.getByTestId('edit-cred-2'))
|
||||
fireEvent.click(screen.getByTestId('delete-cred-2'))
|
||||
|
||||
expect(mockActivate).not.toHaveBeenCalled()
|
||||
expect(mockHandleOpenModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ credential_id: 'cred-2' }),
|
||||
)
|
||||
expect(mockOpenConfirmDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ credential_id: 'cred-2' }),
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add API Key', () => {
|
||||
|
||||
@ -2,8 +2,7 @@ import type { Credential, CustomModel, ModelProvider } from '../../declarations'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import CredentialItem from '../../model-auth/authorized/credential-item'
|
||||
|
||||
type ApiKeySectionProps = {
|
||||
@ -29,8 +28,7 @@ function ApiKeySection({
|
||||
}: ApiKeySectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage')
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
|
||||
if (!credentials.length) {
|
||||
return (
|
||||
@ -45,7 +43,7 @@ function ApiKeySection({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!notAllowCustomCredential && canManageCredential && (
|
||||
{!notAllowCustomCredential && canCreateCredential && (
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
className="w-full"
|
||||
@ -71,7 +69,7 @@ function ApiKeySection({
|
||||
disabled={isActivating}
|
||||
showSelectedIcon
|
||||
selectedCredentialId={selectedCredentialId}
|
||||
onItemClick={onItemClick}
|
||||
onItemClick={canUseCredential ? onItemClick : undefined}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
disableEdit={!canManageCredential}
|
||||
@ -80,7 +78,7 @@ function ApiKeySection({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!notAllowCustomCredential && canManageCredential && (
|
||||
{!notAllowCustomCredential && canCreateCredential && (
|
||||
<div className="p-2">
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import { ConfigurationMethodEnum } from '../../declarations'
|
||||
import { useAuth } from '../../model-auth/hooks'
|
||||
import ApiKeySection from './api-key-section'
|
||||
@ -38,6 +39,7 @@ function DropdownContent({
|
||||
}: DropdownContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const { available_credentials } = provider.custom_configuration
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
|
||||
const {
|
||||
openConfirmDelete,
|
||||
@ -51,19 +53,28 @@ function DropdownContent({
|
||||
const { selectedCredentialId, isActivating, activate } = useActivateCredential(provider)
|
||||
|
||||
const handleEdit = useCallback((credential?: Credential) => {
|
||||
if (credential ? !canManageCredential : !canCreateCredential)
|
||||
return
|
||||
|
||||
handleOpenModal(credential)
|
||||
onClose()
|
||||
}, [handleOpenModal, onClose])
|
||||
}, [canCreateCredential, canManageCredential, handleOpenModal, onClose])
|
||||
|
||||
const handleDelete = useCallback((credential?: Credential) => {
|
||||
if (!canManageCredential)
|
||||
return
|
||||
|
||||
if (credential)
|
||||
openConfirmDelete(credential)
|
||||
}, [openConfirmDelete])
|
||||
}, [canManageCredential, openConfirmDelete])
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!canCreateCredential)
|
||||
return
|
||||
|
||||
handleOpenModal()
|
||||
onClose()
|
||||
}, [handleOpenModal, onClose])
|
||||
}, [canCreateCredential, handleOpenModal, onClose])
|
||||
|
||||
const showCreditsExhaustedAlert = state.isCreditsExhausted && state.supportsCredits
|
||||
const hasApiKeyFallback = state.variant === 'api-fallback'
|
||||
@ -79,7 +90,7 @@ function DropdownContent({
|
||||
{state.showPrioritySwitcher && (
|
||||
<UsagePrioritySection
|
||||
value={state.priority}
|
||||
disabled={isChangingPriority}
|
||||
disabled={isChangingPriority || !canUseCredential}
|
||||
onSelect={onChangePriority}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -284,10 +284,15 @@ const appContextValue: AppContextValue = {
|
||||
workspacePermissionKeys: ownerWorkspacePermissionKeys,
|
||||
}
|
||||
|
||||
type MainNavSystemFeatures = NonNullable<Parameters<typeof renderWithSystemFeatures>[1]>['systemFeatures']
|
||||
type MainNavSystemFeatures = Exclude<NonNullable<Parameters<typeof renderWithSystemFeatures>[1]>['systemFeatures'], null | undefined>
|
||||
|
||||
const defaultMainNavSystemFeatures: MainNavSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
enable_marketplace: true,
|
||||
}
|
||||
|
||||
const renderMainNav = (
|
||||
systemFeatures: MainNavSystemFeatures = { branding: { enabled: false } },
|
||||
systemFeatures: MainNavSystemFeatures = defaultMainNavSystemFeatures,
|
||||
options: { store?: ReturnType<typeof createStore>, extra?: ReactNode } = {},
|
||||
) => {
|
||||
const queryClient = createTestQueryClient()
|
||||
@ -295,12 +300,20 @@ const renderMainNav = (
|
||||
const currentAppContext = getMockAppContext() as AppContextValue
|
||||
queryClient.setQueryData(consoleQuery.workspaces.current.post.queryKey(), currentAppContext.currentWorkspace)
|
||||
queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces })
|
||||
const resolvedSystemFeatures = {
|
||||
...defaultMainNavSystemFeatures,
|
||||
...systemFeatures,
|
||||
branding: {
|
||||
...defaultMainNavSystemFeatures.branding,
|
||||
...systemFeatures.branding,
|
||||
},
|
||||
}
|
||||
return renderWithSystemFeatures(
|
||||
<JotaiProvider store={options.store}>
|
||||
<MainNav />
|
||||
{options.extra}
|
||||
</JotaiProvider>,
|
||||
{ systemFeatures, queryClient },
|
||||
{ systemFeatures: resolvedSystemFeatures, queryClient },
|
||||
)
|
||||
}
|
||||
|
||||
@ -381,6 +394,12 @@ describe('MainNav', () => {
|
||||
expect(screen.queryByRole('link', { name: /common.menus.roster/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the marketplace entry when marketplace is disabled', () => {
|
||||
renderMainNav({ enable_marketplace: false })
|
||||
|
||||
expect(screen.queryByRole('link', { name: /common.mainNav.marketplace/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders deployments in primary navigation when app deploy is enabled', () => {
|
||||
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
|
||||
|
||||
|
||||
@ -181,6 +181,7 @@ const MainNav = ({
|
||||
agentV2Enabled,
|
||||
canUseAppDeploy,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
marketplaceEnabled: systemFeatures.enable_marketplace,
|
||||
}))
|
||||
.map(route => ({
|
||||
href: route.href,
|
||||
@ -188,7 +189,7 @@ const MainNav = ({
|
||||
active: route.active,
|
||||
icon: route.icon,
|
||||
activeIcon: route.activeIcon,
|
||||
})), [agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, t])
|
||||
})), [agentV2Enabled, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, systemFeatures.enable_marketplace, t])
|
||||
|
||||
const renderLogo = () => {
|
||||
const appTitle = systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'
|
||||
|
||||
@ -10,13 +10,14 @@ export type MainNavRouteConfig = {
|
||||
icon: string
|
||||
activeIcon: string
|
||||
visibility: MainNavRouteVisibility
|
||||
feature?: 'agentV2'
|
||||
feature?: 'agentV2' | 'marketplace'
|
||||
}
|
||||
|
||||
export type MainNavRouteVisibilityOptions = {
|
||||
agentV2Enabled: boolean
|
||||
canUseAppDeploy: boolean
|
||||
isCurrentWorkspaceDatasetOperator: boolean
|
||||
marketplaceEnabled: boolean
|
||||
}
|
||||
|
||||
function isPathUnderRoute(pathname: string, route: string) {
|
||||
@ -78,6 +79,7 @@ export const MAIN_NAV_ROUTES = [
|
||||
icon: 'i-custom-vender-main-nav-marketplace',
|
||||
activeIcon: 'i-custom-vender-main-nav-marketplace-active',
|
||||
visibility: 'all',
|
||||
feature: 'marketplace',
|
||||
},
|
||||
{
|
||||
key: 'deployments',
|
||||
@ -94,6 +96,9 @@ export function isMainNavRouteVisible(route: MainNavRouteConfig, options: MainNa
|
||||
if (route.feature === 'agentV2' && !options.agentV2Enabled)
|
||||
return false
|
||||
|
||||
if (route.feature === 'marketplace' && !options.marketplaceEnabled)
|
||||
return false
|
||||
|
||||
if (route.visibility === 'all')
|
||||
return true
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { userProfile: typeof mockUserProfile, workspacePermissionKeys: string[] }) => unknown) =>
|
||||
selector({
|
||||
userProfile: mockUserProfile,
|
||||
workspacePermissionKeys: ['credential.manage', 'credential.use'],
|
||||
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'],
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import { AuthCategory } from '../types'
|
||||
const mockUsePluginAuth = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockAppContext = vi.hoisted(() => ({
|
||||
workspacePermissionKeys: ['credential.manage', 'credential.use'] as string[],
|
||||
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[],
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-plugin-auth', () => ({
|
||||
@ -44,7 +44,7 @@ const defaultPayload = {
|
||||
describe('PluginAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppContext.workspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
mockAppContext.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -142,7 +142,7 @@ describe('PluginAuth', () => {
|
||||
expect(mockUsePluginAuth).toHaveBeenCalledWith(defaultPayload, true)
|
||||
})
|
||||
|
||||
it('renders permission hint and disables authorization configuration when credential.manage is missing', () => {
|
||||
it('renders permission hint and disables authorization configuration when credential.create is missing', () => {
|
||||
mockAppContext.workspacePermissionKeys = ['credential.use']
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
|
||||
@ -29,7 +29,7 @@ const createWrapper = () => {
|
||||
// Mock API hooks - only mock network-related hooks
|
||||
const mockGetPluginOAuthClientSchema = vi.fn()
|
||||
const mockAppContext = vi.hoisted(() => ({
|
||||
workspacePermissionKeys: ['credential.manage', 'credential.use'] as string[],
|
||||
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[],
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/use-credential', () => ({
|
||||
@ -94,7 +94,7 @@ const createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayl
|
||||
describe('Authorize', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppContext.workspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
mockAppContext.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
mockGetPluginOAuthClientSchema.mockReturnValue({
|
||||
schema: [],
|
||||
is_oauth_custom_client_enabled: false,
|
||||
@ -251,8 +251,8 @@ describe('Authorize', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('credential.manage permission', () => {
|
||||
it('should disable OAuth button when credential.manage is missing', () => {
|
||||
describe('credential.create permission', () => {
|
||||
it('should disable OAuth button when credential.create is missing', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
mockAppContext.workspacePermissionKeys = ['credential.use']
|
||||
|
||||
@ -267,7 +267,7 @@ describe('Authorize', () => {
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable API Key button when credential.manage is missing', () => {
|
||||
it('should disable API Key button when credential.create is missing', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
mockAppContext.workspacePermissionKeys = ['credential.use']
|
||||
|
||||
@ -282,7 +282,7 @@ describe('Authorize', () => {
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable buttons when credential.manage is present', () => {
|
||||
it('should not disable buttons when credential.create is present', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
@ -548,7 +548,7 @@ describe('Authorize', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should stay disabled when credential.manage is missing and custom credentials are unavailable', () => {
|
||||
it('should stay disabled when credential.create is missing and custom credentials are unavailable', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
mockAppContext.workspacePermissionKeys = ['credential.use']
|
||||
|
||||
|
||||
@ -8,8 +8,7 @@ import {
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import AddApiKeyButton from './add-api-key-button'
|
||||
import AddOAuthButton from './add-oauth-button'
|
||||
|
||||
@ -40,8 +39,7 @@ const Authorize = ({
|
||||
onApiKeyClick,
|
||||
}: AuthorizeProps) => {
|
||||
const { t } = useTranslation()
|
||||
const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys)
|
||||
const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage')
|
||||
const { canCreateCredential } = useCredentialPermissions()
|
||||
|
||||
const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => {
|
||||
if (theme === 'secondary') {
|
||||
@ -84,7 +82,7 @@ const Authorize = ({
|
||||
<div className={cn('min-w-0 flex-1', notAllowCustomCredential && 'opacity-50')}>
|
||||
<AddOAuthButton
|
||||
{...oAuthButtonProps}
|
||||
disabled={!canManageCredential || notAllowCustomCredential}
|
||||
disabled={!canCreateCredential || notAllowCustomCredential}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
@ -101,14 +99,14 @@ const Authorize = ({
|
||||
)
|
||||
}
|
||||
return Item
|
||||
}, [notAllowCustomCredential, oAuthButtonProps, canManageCredential, onUpdate, t])
|
||||
}, [notAllowCustomCredential, oAuthButtonProps, canCreateCredential, onUpdate, t])
|
||||
|
||||
const ApiKeyButton = useMemo(() => {
|
||||
const Item = (
|
||||
<div className={cn('min-w-0 flex-1', notAllowCustomCredential && 'opacity-50')}>
|
||||
<AddApiKeyButton
|
||||
{...apiKeyButtonProps}
|
||||
disabled={!canManageCredential || notAllowCustomCredential}
|
||||
disabled={!canCreateCredential || notAllowCustomCredential}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
@ -125,7 +123,7 @@ const Authorize = ({
|
||||
)
|
||||
}
|
||||
return Item
|
||||
}, [notAllowCustomCredential, apiKeyButtonProps, canManageCredential, onUpdate, t])
|
||||
}, [notAllowCustomCredential, apiKeyButtonProps, canCreateCredential, onUpdate, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -77,7 +77,7 @@ vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/bas
|
||||
|
||||
const mockAppContext = vi.hoisted(() => ({
|
||||
userProfile: { id: 'test-user', name: 'Test User', email: 'test@example.com', avatar_url: '' },
|
||||
workspacePermissionKeys: ['credential.manage', 'credential.use'] as string[],
|
||||
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[],
|
||||
}))
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: {
|
||||
@ -141,7 +141,7 @@ const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
|
||||
describe('Authorized Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppContext.workspacePermissionKeys = ['credential.manage', 'credential.use']
|
||||
mockAppContext.workspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
mockDeletePluginCredential.mockResolvedValue({})
|
||||
mockSetPluginDefaultCredential.mockResolvedValue({})
|
||||
mockUpdatePluginCredential.mockResolvedValue({})
|
||||
|
||||
@ -12,7 +12,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { userProfile: typeof mockUserProfile, workspacePermissionKeys: string[] }) => unknown) =>
|
||||
selector({
|
||||
userProfile: mockUserProfile,
|
||||
workspacePermissionKeys: ['credential.manage', 'credential.use'],
|
||||
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'],
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@ -79,7 +79,7 @@ const Authorized = ({
|
||||
notAllowCustomCredential,
|
||||
}: AuthorizedProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { canUseCredential, canManageCredential } = useCredentialPermissions()
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(false)
|
||||
const mergedIsOpen = isOpen ?? isLocalOpen
|
||||
const setMergedIsOpen = useCallback((open: boolean) => {
|
||||
@ -152,12 +152,12 @@ const Authorized = ({
|
||||
// popover closes due to outside-click detection on the modal's portal.
|
||||
const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false)
|
||||
const handleAddApiKeyClick = useCallback(() => {
|
||||
if (!canManageCredential)
|
||||
if (!canCreateCredential)
|
||||
return
|
||||
|
||||
setMergedIsOpen(false)
|
||||
setIsAddApiKeyOpen(true)
|
||||
}, [canManageCredential, setMergedIsOpen])
|
||||
}, [canCreateCredential, setMergedIsOpen])
|
||||
const handleRemove = useCallback(() => {
|
||||
if (!canManageCredential)
|
||||
return
|
||||
@ -394,7 +394,7 @@ const Authorized = ({
|
||||
onOpenChange={setIsAddApiKeyOpen}
|
||||
pluginPayload={pluginPayload}
|
||||
onClose={() => setIsAddApiKeyOpen(false)}
|
||||
disabled={!canManageCredential || doingAction}
|
||||
disabled={!canCreateCredential || doingAction}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -16,7 +16,7 @@ import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
|
||||
type ItemProps = {
|
||||
@ -55,9 +55,7 @@ const Item = ({
|
||||
const { t } = useTranslation()
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState(credential.name)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys)
|
||||
const canManageCredential = hasPermission(workspacePermissionKeys, 'credential.manage')
|
||||
const canUseCredential = hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage'])
|
||||
const { canUseCredential, canManageCredential } = useCredentialPermissions()
|
||||
const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2
|
||||
const isPersonal = credential.visibility === 'only_me'
|
||||
const userProfile = useAppContextWithSelector(state => state.userProfile)
|
||||
|
||||
@ -14,7 +14,7 @@ vi.mock('@/i18n-config/language', () => ({
|
||||
|
||||
const mockIsCurrentWorkspaceManager = vi.fn(() => true)
|
||||
const mockAppContextState = vi.hoisted(() => ({
|
||||
workspacePermissionKeys: ['tool.manage', 'credential.manage', 'credential.use'] as string[],
|
||||
workspacePermissionKeys: ['tool.manage', 'credential.use', 'credential.create', 'credential.manage'] as string[],
|
||||
}))
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
@ -173,7 +173,7 @@ describe('ProviderDetail', () => {
|
||||
])
|
||||
mockFetchCustomToolList.mockResolvedValue([])
|
||||
mockFetchModelToolList.mockResolvedValue([])
|
||||
mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.manage', 'credential.use']
|
||||
mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.use', 'credential.create', 'credential.manage']
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -535,8 +535,8 @@ describe('ProviderDetail', () => {
|
||||
expect(screen.getByTestId('config-credential'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not open setup credential drawer without credential.manage', async () => {
|
||||
mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.use']
|
||||
it('does not open setup credential drawer without credential.create', async () => {
|
||||
mockAppContextState.workspacePermissionKeys = ['tool.manage', 'credential.use', 'credential.manage']
|
||||
|
||||
render(
|
||||
<ProviderDetail
|
||||
|
||||
@ -82,8 +82,9 @@ const ProviderDetail = ({
|
||||
const isAuthed = collection.is_team_authorization
|
||||
const isBuiltIn = collection.type === CollectionType.builtIn
|
||||
const isModel = collection.type === CollectionType.model
|
||||
const { canUseCredential, canManageCredential } = useCredentialPermissions()
|
||||
const canOpenCredentialSettings = isAuthed ? canUseCredential : canManageCredential
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const canOpenCredentialSettings = isAuthed ? canUseCredential : canCreateCredential
|
||||
const canSaveCredentialSettings = isAuthed ? canManageCredential : canCreateCredential
|
||||
const canManageTools = useCanManageTools()
|
||||
const invalidateAllWorkflowTools = useInvalidateAllWorkflowTools()
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false)
|
||||
@ -432,7 +433,7 @@ const ProviderDetail = ({
|
||||
collection={collection}
|
||||
onCancel={() => setShowSettingAuth(false)}
|
||||
onSaved={async (value) => {
|
||||
if (!canManageCredential)
|
||||
if (!canSaveCredentialSettings)
|
||||
return
|
||||
|
||||
await updateBuiltInToolCredential(collection.name, value)
|
||||
@ -449,7 +450,7 @@ const ProviderDetail = ({
|
||||
await onRefreshData()
|
||||
setShowSettingAuth(false)
|
||||
}}
|
||||
readonly={!canManageCredential}
|
||||
readonly={!canSaveCredentialSettings}
|
||||
/>
|
||||
)}
|
||||
{isShowEditCollectionToolModal && canManageTools && (
|
||||
|
||||
53
web/hooks/use-credential-permissions.spec.ts
Normal file
53
web/hooks/use-credential-permissions.spec.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useCredentialPermissions } from './use-credential-permissions'
|
||||
|
||||
let mockWorkspacePermissionKeys: string[] | null = []
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { workspacePermissionKeys: string[] | null }) => unknown) => selector({
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useCredentialPermissions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspacePermissionKeys = []
|
||||
})
|
||||
|
||||
it('should expose separate use, create, and manage credential capabilities', () => {
|
||||
mockWorkspacePermissionKeys = ['credential.use', 'credential.create', 'credential.manage']
|
||||
|
||||
const { result } = renderHook(() => useCredentialPermissions())
|
||||
|
||||
expect(result.current).toEqual({
|
||||
canUseCredential: true,
|
||||
canCreateCredential: true,
|
||||
canManageCredential: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not grant credential use from create or manage permissions', () => {
|
||||
mockWorkspacePermissionKeys = ['credential.create', 'credential.manage']
|
||||
|
||||
const { result } = renderHook(() => useCredentialPermissions())
|
||||
|
||||
expect(result.current).toEqual({
|
||||
canUseCredential: false,
|
||||
canCreateCredential: true,
|
||||
canManageCredential: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing workspace permissions as no credential capabilities', () => {
|
||||
mockWorkspacePermissionKeys = null
|
||||
|
||||
const { result } = renderHook(() => useCredentialPermissions())
|
||||
|
||||
expect(result.current).toEqual({
|
||||
canUseCredential: false,
|
||||
canCreateCredential: false,
|
||||
canManageCredential: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -5,7 +5,8 @@ export const useCredentialPermissions = () => {
|
||||
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
|
||||
|
||||
return {
|
||||
canUseCredential: hasPermission(workspacePermissionKeys, ['credential.use', 'credential.manage']),
|
||||
canUseCredential: hasPermission(workspacePermissionKeys, 'credential.use'),
|
||||
canCreateCredential: hasPermission(workspacePermissionKeys, 'credential.create'),
|
||||
canManageCredential: hasPermission(workspacePermissionKeys, 'credential.manage'),
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,8 +16,9 @@
|
||||
"billing.manage": "Change subscription plans",
|
||||
"billing.subscription.manage": "Manage billing and subscriptions in the billing portal",
|
||||
"billing.view": "Access Billing settings",
|
||||
"credential.manage": "Manage credentials",
|
||||
"credential.use": "Use credentials",
|
||||
"credential.create": "Add credentials",
|
||||
"credential.manage": "Edit and delete credentials",
|
||||
"credential.use": "View and use credentials",
|
||||
"customization.manage": "Manage customization",
|
||||
"data_source.manage": "Manage data source configuration",
|
||||
"dataset.access_config": "Configure knowledge base access permissions",
|
||||
|
||||
@ -46,6 +46,7 @@
|
||||
"group.credential": "Credentials",
|
||||
"group.dataset": "Knowledge bases",
|
||||
"group.dataset_acl": "Knowledge base access permissions",
|
||||
"group.integration": "Integrations",
|
||||
"group.plugin": "Plugins",
|
||||
"group.tool_mcp": "Tools and MCP",
|
||||
"group.workspace": "Workspace",
|
||||
|
||||
@ -16,8 +16,9 @@
|
||||
"billing.manage": "サブスクリプションプランを変更",
|
||||
"billing.subscription.manage": "請求ポータルで請求とサブスクリプションを管理",
|
||||
"billing.view": "設定の請求ページにアクセス",
|
||||
"credential.manage": "認証情報を管理",
|
||||
"credential.use": "認証情報を使用",
|
||||
"credential.create": "認証情報を追加",
|
||||
"credential.manage": "認証情報を編集・削除",
|
||||
"credential.use": "認証情報を表示して使用",
|
||||
"customization.manage": "カスタマイズを管理",
|
||||
"data_source.manage": "データソース設定を管理",
|
||||
"dataset.access_config": "ナレッジベースのアクセス権限を設定",
|
||||
|
||||
@ -46,6 +46,7 @@
|
||||
"group.credential": "認証情報",
|
||||
"group.dataset": "ナレッジベース",
|
||||
"group.dataset_acl": "ナレッジベースアクセス権限",
|
||||
"group.integration": "インテグレーション",
|
||||
"group.plugin": "プラグイン",
|
||||
"group.tool_mcp": "ツールとMCP",
|
||||
"group.workspace": "ワークスペース",
|
||||
|
||||
@ -16,8 +16,9 @@
|
||||
"billing.manage": "更换订阅套餐",
|
||||
"billing.subscription.manage": "在账单门户管理账单与订阅方案",
|
||||
"billing.view": "在设置中访问账单页面",
|
||||
"credential.manage": "管理凭据",
|
||||
"credential.use": "使用凭据",
|
||||
"credential.create": "添加凭据",
|
||||
"credential.manage": "编辑和删除凭据",
|
||||
"credential.use": "查看和使用凭据",
|
||||
"customization.manage": "管理定制",
|
||||
"data_source.manage": "管理数据来源",
|
||||
"dataset.access_config": "配置知识库访问权限",
|
||||
|
||||
@ -46,6 +46,7 @@
|
||||
"group.credential": "凭据",
|
||||
"group.dataset": "知识库",
|
||||
"group.dataset_acl": "知识库访问权限",
|
||||
"group.integration": "集成",
|
||||
"group.plugin": "插件",
|
||||
"group.tool_mcp": "工具与MCP",
|
||||
"group.workspace": "工作空间",
|
||||
|
||||
@ -16,8 +16,9 @@
|
||||
"billing.manage": "更換訂閱套餐",
|
||||
"billing.subscription.manage": "在帳單入口網站管理帳單與訂閱方案",
|
||||
"billing.view": "在設定中訪問帳單頁面",
|
||||
"credential.manage": "管理憑據",
|
||||
"credential.use": "使用憑據",
|
||||
"credential.create": "新增憑據",
|
||||
"credential.manage": "編輯和刪除憑據",
|
||||
"credential.use": "檢視和使用憑據",
|
||||
"customization.manage": "管理自訂",
|
||||
"data_source.manage": "管理資料來源配置",
|
||||
"dataset.access_config": "配置知識庫訪問權限",
|
||||
|
||||
@ -46,6 +46,7 @@
|
||||
"group.credential": "憑據",
|
||||
"group.dataset": "知識庫",
|
||||
"group.dataset_acl": "知識庫訪問權限",
|
||||
"group.integration": "整合",
|
||||
"group.plugin": "插件",
|
||||
"group.tool_mcp": "工具與MCP",
|
||||
"group.workspace": "工作空間",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user