From 76e587f78a5d1b272a7a318923ee28de0b078566 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:09:07 +0800 Subject: [PATCH] fix(tests): enhance toast mock and add preview-only app warning test (#37749) --- .../tools/tool-provider-detail-flow.test.tsx | 2 +- .../explore/app-list/__tests__/index.spec.tsx | 47 ++++++ .../continue-work/__tests__/item.spec.tsx | 154 ++++++++++++++++++ .../components/explore/continue-work/item.tsx | 46 +++++- .../__tests__/card.spec.tsx | 6 +- .../data-source-page-new/card.tsx | 4 +- .../add-credential-in-load-balancing.spec.tsx | 56 ++++++- .../__tests__/add-custom-model.spec.tsx | 32 +++- ...itch-credential-in-load-balancing.spec.tsx | 68 ++++++-- .../add-credential-in-load-balancing.tsx | 14 +- .../model-auth/add-custom-model.tsx | 23 +-- .../authorized/__tests__/index.spec.tsx | 53 +++++- .../model-auth/authorized/index.tsx | 23 ++- .../switch-credential-in-load-balancing.tsx | 27 +-- .../model-modal/__tests__/dialog.spec.tsx | 2 +- .../model-modal/__tests__/index.spec.tsx | 4 +- .../model-provider-page/model-modal/index.tsx | 16 +- .../__tests__/popup-item.spec.tsx | 20 ++- .../model-selector/popup-item.tsx | 16 +- .../__tests__/index.spec.tsx | 6 +- .../provider-added-card/index.tsx | 6 +- .../__tests__/api-key-section.spec.tsx | 2 +- .../__tests__/dropdown-content.spec.tsx | 58 ++++++- .../model-auth-dropdown/api-key-section.tsx | 12 +- .../model-auth-dropdown/dropdown-content.tsx | 19 ++- .../main-nav/__tests__/index.spec.tsx | 25 ++- web/app/components/main-nav/index.tsx | 3 +- web/app/components/main-nav/routes.ts | 7 +- .../__tests__/plugin-auth-in-agent.spec.tsx | 2 +- .../__tests__/plugin-auth.spec.tsx | 6 +- .../authorize/__tests__/index.spec.tsx | 14 +- .../plugins/plugin-auth/authorize/index.tsx | 14 +- .../authorized/__tests__/index.spec.tsx | 4 +- .../authorized/__tests__/item.spec.tsx | 2 +- .../plugins/plugin-auth/authorized/index.tsx | 8 +- .../plugins/plugin-auth/authorized/item.tsx | 6 +- .../tools/provider/__tests__/detail.spec.tsx | 8 +- web/app/components/tools/provider/detail.tsx | 9 +- web/hooks/use-credential-permissions.spec.ts | 53 ++++++ web/hooks/use-credential-permissions.ts | 3 +- web/i18n/en-US/permission-keys.json | 5 +- web/i18n/en-US/permission.json | 1 + web/i18n/ja-JP/permission-keys.json | 5 +- web/i18n/ja-JP/permission.json | 1 + web/i18n/zh-Hans/permission-keys.json | 5 +- web/i18n/zh-Hans/permission.json | 1 + web/i18n/zh-Hant/permission-keys.json | 5 +- web/i18n/zh-Hant/permission.json | 1 + 48 files changed, 742 insertions(+), 162 deletions(-) create mode 100644 web/app/components/explore/continue-work/__tests__/item.spec.tsx create mode 100644 web/hooks/use-credential-permissions.spec.ts diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index e99e7ac24c2..8cbfae8d693 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -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'], }), })) diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 0bc33b5a751..a11efc38143 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -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) => record({ message, ...options })), { + success: vi.fn((message: unknown, options?: Record) => record({ type: 'success', message, ...options })), + error: vi.fn((message: unknown, options?: Record) => record({ type: 'error', message, ...options })), + warning: vi.fn((message: unknown, options?: Record) => record({ type: 'warning', message, ...options })), + info: vi.fn((message: unknown, options?: Record) => 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'], diff --git a/web/app/components/explore/continue-work/__tests__/item.spec.tsx b/web/app/components/explore/continue-work/__tests__/item.spec.tsx new file mode 100644 index 00000000000..275ee584993 --- /dev/null +++ b/web/app/components/explore/continue-work/__tests__/item.spec.tsx @@ -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 & { children?: ReactNode, href: string }) => ( + {children} + ), +})) + +const createApp = (overrides: Partial = {}): 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[1]>['systemFeatures'] = { rbac_enabled: true }, +) => renderWithSystemFeatures(, { 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() + }) +}) diff --git a/web/app/components/explore/continue-work/item.tsx b/web/app/components/explore/continue-work/item.tsx index 170cd0c7e49..570dae473c1 100644 --- a/web/app/components/explore/continue-work/item.tsx +++ b/web/app/components/explore/continue-work/item.tsx @@ -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 ( - + const showPreviewOnlyAccessWarning = () => { + toast.warning(t('noAccessResourcePermission', { ns: 'app' })) + } + + const handlePreviewOnlyCardKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') + return + + event.preventDefault() + showPreviewOnlyAccessWarning() + } + + const cardContent = ( + <>
{t('continueWork.editedAt', { ns: 'explore', time: formatTimeFromNow(updatedAt) })}
+ + ) + + if (isPreviewOnly) { + return ( +
+ {cardContent} +
+ ) + } + + return ( + + {cardContent} ) } diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx index 5ce1c8f72c1..0318645ea9f 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/card.spec.tsx @@ -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 = { diff --git a/web/app/components/header/account-setting/data-source-page-new/card.tsx b/web/app/components/header/account-setting/data-source-page-new/card.tsx index 0eddcb5d394..a972893e7ec 100644 --- a/web/app/components/header/account-setting/data-source-page-new/card.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/card.tsx @@ -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} /> diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx index 16b3dc3fb54..c8ec3a1f2a2 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-credential-in-load-balancing.spec.tsx @@ -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) => void } items: Array<{ credentials: Array<{ credential_id: string, credential_name: string }> }> onItemClick?: (credential: { credential_id: string, credential_name: string }) => void + hideAddAction?: boolean + triggerOnlyOpenModal?: boolean }) => ( -
+
{renderTrigger(false)} @@ -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( + , + ) + + 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( + , + ) + + 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', () => ({ diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx index 3bfa1029474..7230be997f4 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx @@ -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( + , + ) + + 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( diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx index e871b35954f..3199e26692c 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/switch-credential-in-load-balancing.spec.tsx @@ -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[] }[] }) => ( -
-
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 + }) => ( +
+
onItemClick?.(items[0]!.credentials[0])}> {renderTrigger()}
@@ -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( { />, ) - 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( + , + ) + + 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( { // 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() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx index 1f27fc96788..4bf5002f3a9 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx @@ -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) => { @@ -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' }) : ''} /> diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx index d7c677b06c4..def6e42fffe 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx @@ -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 = ( ) - if ((empty && notAllowCustomCredential) || !canUseCredential) { + if ((empty && notAllowCustomCredential) || !canOpenCredentialMenu) { return ( @@ -106,7 +110,7 @@ const SwitchCredentialInLoadBalancing = ({ ) } return Item - }, [canUseCredential, customModelCredential, t, credentials, notAllowCustomCredential]) + }, [canCreateCredential, canOpenCredentialMenu, customModelCredential, t, credentials, notAllowCustomCredential]) return ( ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx index cfb241be7d5..e1a8ac24cf2 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/dialog.spec.tsx @@ -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', () => ({ diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx index f1d672299c0..b3dad54e25e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/__tests__/index.spec.tsx @@ -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, 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 = [] diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx index 0c7f68fb361..245554c5c1b 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx @@ -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 = ({ 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 = ({ return } - if (!canManageCredential) + const canSubmitCredentialForm = credential ? canManageCredential : canCreateCredential + if (!canSubmitCredentialForm) return let modelNameAndTypeIsCheckValidated = true @@ -197,7 +195,7 @@ const ModelModal: FC = ({ }) } 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 = ({ }, [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 = ({ onSelect={setSelectedCredential} selectedCredential={selectedCredential} disabled={isLoading} - notAllowAddNewCredential={notAllowCustomCredential || !canManageCredential} + notAllowAddNewCredential={notAllowCustomCredential || !canCreateCredential} /> ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index f8511a17855..291b903f62a 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -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() + + const trigger = screen.getByRole('button', { name: /my-api-key/ }) + + expect(trigger).not.toBeDisabled() + + fireEvent.click(trigger) + + expect(screen.getByRole('button', { name: 'close dropdown' })).toBeInTheDocument() + }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index a957ae16577..f0d99838c55 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -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({ {isUsingCredits @@ -142,7 +140,7 @@ function PopupItem({ {t('modelProvider.selector.configureRequired', { ns: 'common' })} )} - {canUseCredentials && } + {canOpenCredentialDropdown && } )} /> @@ -193,7 +191,7 @@ function PopupItem({ onPointerDown={onPreviewCardClose} > {rowContent} - {canManageCredentials && ( + {canCreateCredential && ( +
@@ -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( + , + ) + + 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( + , + ) + + 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', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx index eba042cb48f..57caf2c1c2c 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/api-key-section.tsx @@ -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({
- {!notAllowCustomCredential && canManageCredential && ( + {!notAllowCustomCredential && canCreateCredential && (