fix(tests): enhance toast mock and add preview-only app warning test (#37749)

This commit is contained in:
Wu Tianwei 2026-06-22 18:09:07 +08:00 committed by GitHub
parent 99010dab3e
commit 76e587f78a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 742 additions and 162 deletions

View File

@ -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'],
}),
}))

View File

@ -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'],

View 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()
})
})

View File

@ -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>
)
}

View File

@ -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 = {

View File

@ -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>

View File

@ -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', () => ({

View File

@ -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(

View File

@ -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()
})

View File

@ -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' }) : ''}
/>

View File

@ -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={() => {

View File

@ -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'

View File

@ -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()}

View File

@ -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}
/>
)
}

View File

@ -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', () => ({

View File

@ -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 = []

View File

@ -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}
/>
)
}

View File

@ -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()
})
})

View File

@ -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"

View File

@ -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()
})

View File

@ -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) => {

View File

@ -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'],
}),
}))

View File

@ -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', () => {

View File

@ -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}

View File

@ -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}
/>
)}

View File

@ -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 })

View File

@ -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'

View File

@ -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

View File

@ -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'],
}),
}))

View File

@ -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,

View File

@ -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']

View File

@ -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 (
<>

View File

@ -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({})

View File

@ -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'],
}),
}))

View File

@ -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}
/>
)

View File

@ -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)

View File

@ -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

View File

@ -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 && (

View 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,
})
})
})

View File

@ -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'),
}
}

View File

@ -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",

View File

@ -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",

View File

@ -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": "ナレッジベースのアクセス権限を設定",

View File

@ -46,6 +46,7 @@
"group.credential": "認証情報",
"group.dataset": "ナレッジベース",
"group.dataset_acl": "ナレッジベースアクセス権限",
"group.integration": "インテグレーション",
"group.plugin": "プラグイン",
"group.tool_mcp": "ツールとMCP",
"group.workspace": "ワークスペース",

View File

@ -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": "配置知识库访问权限",

View File

@ -46,6 +46,7 @@
"group.credential": "凭据",
"group.dataset": "知识库",
"group.dataset_acl": "知识库访问权限",
"group.integration": "集成",
"group.plugin": "插件",
"group.tool_mcp": "工具与MCP",
"group.workspace": "工作空间",

View File

@ -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": "配置知識庫訪問權限",

View File

@ -46,6 +46,7 @@
"group.credential": "憑據",
"group.dataset": "知識庫",
"group.dataset_acl": "知識庫訪問權限",
"group.integration": "整合",
"group.plugin": "插件",
"group.tool_mcp": "工具與MCP",
"group.workspace": "工作空間",