diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx index f72100e9a8f..4b3849cc8bb 100644 --- a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx +++ b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx @@ -192,7 +192,7 @@ describe('SnippetInfoDropdown', () => { await user.click(screen.getByRole('button')) expect(screen.getByText('snippet.menu.editInfo')).toBeInTheDocument() - expect(screen.queryByText('snippet.menu.exportSnippet')).not.toBeInTheDocument() + expect(screen.getByText('snippet.menu.exportSnippet')).toBeInTheDocument() expect(screen.queryByText('snippet.menu.deleteSnippet')).not.toBeInTheDocument() unmount() @@ -201,7 +201,7 @@ describe('SnippetInfoDropdown', () => { await user.click(screen.getByRole('button')) expect(screen.queryByText('snippet.menu.editInfo')).not.toBeInTheDocument() - expect(screen.getByText('snippet.menu.exportSnippet')).toBeInTheDocument() + expect(screen.queryByText('snippet.menu.exportSnippet')).not.toBeInTheDocument() expect(screen.getByText('snippet.menu.deleteSnippet')).toBeInTheDocument() }) }) @@ -244,7 +244,7 @@ describe('SnippetInfoDropdown', () => { describe('Export Snippet', () => { it('should export and download the snippet yaml', async () => { const user = userEvent.setup() - mockWorkspacePermissionKeys = ['snippets.management'] + mockWorkspacePermissionKeys = ['snippets.create_and_modify'] mockExportMutateAsync.mockResolvedValue('yaml: content') render() @@ -264,7 +264,7 @@ describe('SnippetInfoDropdown', () => { it('should show an error toast when export fails', async () => { const user = userEvent.setup() - mockWorkspacePermissionKeys = ['snippets.management'] + mockWorkspacePermissionKeys = ['snippets.create_and_modify'] mockExportMutateAsync.mockRejectedValue(new Error('export failed')) render() diff --git a/web/app/components/app-sidebar/snippet-info/dropdown.tsx b/web/app/components/app-sidebar/snippet-info/dropdown.tsx index 00400019f92..a830ab09ac5 100644 --- a/web/app/components/app-sidebar/snippet-info/dropdown.tsx +++ b/web/app/components/app-sidebar/snippet-info/dropdown.tsx @@ -58,7 +58,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { }, []) const handleExportSnippet = React.useCallback(async () => { - if (!canManageSnippet) + if (!canCreateAndModifySnippet) return setOpen(false) @@ -70,7 +70,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { catch { toast.error(t('exportFailed')) } - }, [canManageSnippet, exportSnippetMutation, snippet.id, snippet.name, t]) + }, [canCreateAndModifySnippet, exportSnippetMutation, snippet.id, snippet.name, t]) const handleEditSnippet = React.useCallback(async ({ name, description }: { name: string @@ -125,18 +125,20 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { popupClassName="w-[180px] p-1" > {canCreateAndModifySnippet && ( - - - {t('menu.editInfo')} - - )} - {canManageSnippet && ( <> + + + {t('menu.editInfo')} + {t('menu.exportSnippet')} - + + )} + {canManageSnippet && ( + <> + {canCreateAndModifySnippet && } { expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument() }) + it('fetches snippets without create action for users with snippet management permission', () => { + mockWorkspacePermissionKeys.mockReturnValue(['snippets.management']) + + renderList() + + expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.any(Object), { + enabled: true, + }) + expect(screen.getByRole('link', { name: /Sales Snippet/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument() + }) + it('does not fetch or render snippets without snippet list permissions', () => { mockWorkspacePermissionKeys.mockReturnValue([]) diff --git a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx index 30b063c47a6..d1d809e50dd 100644 --- a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx +++ b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx @@ -177,11 +177,11 @@ describe('SnippetCard', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) expect(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' })).toBeInTheDocument() - expect(screen.queryByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).not.toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).not.toBeInTheDocument() }) - it('should show export and delete with snippet management permission without edit info', async () => { + it('should show delete with snippet management permission without create-and-modify actions', async () => { mockIsCurrentWorkspaceEditor.mockReturnValue(false) mockWorkspacePermissionKeys.mockReturnValue(['snippets.management']) @@ -190,7 +190,7 @@ describe('SnippetCard', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' })) expect(screen.queryByRole('menuitem', { name: 'snippet.menu.editInfo' })).not.toBeInTheDocument() - expect(await screen.findByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).not.toBeInTheDocument() expect(await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument() }) @@ -215,7 +215,7 @@ describe('SnippetCard', () => { }) it('should export a snippet from the operations menu', async () => { - mockWorkspacePermissionKeys.mockReturnValue(['snippets.management']) + mockWorkspacePermissionKeys.mockReturnValue(['snippets.create_and_modify']) mockExportMutateAsync.mockResolvedValue('snippet-yaml') render() @@ -232,7 +232,7 @@ describe('SnippetCard', () => { }) it('should show an error toast when snippet export fails', async () => { - mockWorkspacePermissionKeys.mockReturnValue(['snippets.management']) + mockWorkspacePermissionKeys.mockReturnValue(['snippets.create_and_modify']) mockExportMutateAsync.mockRejectedValue(new Error('export failed')) render() diff --git a/web/app/components/snippet-list/components/snippet-card.tsx b/web/app/components/snippet-list/components/snippet-card.tsx index 96a42ffc13b..06e5929b2e9 100644 --- a/web/app/components/snippet-list/components/snippet-card.tsx +++ b/web/app/components/snippet-list/components/snippet-card.tsx @@ -82,7 +82,7 @@ const SnippetCard = ({ } const handleExportSnippet = async () => { - if (!canManageSnippet) + if (!canCreateAndModifySnippet) return setIsOperationsMenuOpen(false) @@ -155,13 +155,7 @@ const SnippetCard = ({
-
{ - e.stopPropagation() - e.preventDefault() - }} - > +
{canCreateAndModifySnippet && ( - - {t('menu.editInfo')} - - )} - {canManageSnippet && ( <> + + {t('menu.editInfo')} + {t('menu.exportSnippet')} - + + )} + {canManageSnippet && ( + <> + {canCreateAndModifySnippet && } vi.fn()) +const mockAppContext = vi.hoisted(() => ({ + workspacePermissionKeys: ['snippets.create_and_modify'] as string[], +})) const mockInspectVarsCrud = { hasNodeInspectVars: vi.fn(), hasSetInspectVar: vi.fn(), @@ -49,6 +52,12 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({ + workspacePermissionKeys: mockAppContext.workspacePermissionKeys, + }), +})) + let capturedHooksStore: Record | undefined let capturedWorkflowNodes: WorkflowProps['nodes'] | undefined let snippetDetailStoreState: { @@ -153,13 +162,15 @@ vi.mock('@/app/components/snippets/components/snippet-children', () => ({ default: ({ onPublish, canSave, + canEdit, }: { canSave: boolean + canEdit: boolean onPublish: () => void }) => (
snippets list - + {canEdit && }
), })) @@ -245,6 +256,7 @@ const createDraftNode = (id = 'draft-node') => ({ describe('SnippetMain', () => { beforeEach(() => { vi.clearAllMocks() + mockAppContext.workspacePermissionKeys = ['snippets.create_and_modify'] mockDoSyncWorkflowDraft.mockResolvedValue(undefined) mockSyncInputFieldsDraft.mockResolvedValue(undefined) mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 }) @@ -296,15 +308,17 @@ describe('SnippetMain', () => { expect(capturedWorkflowNodes?.map(node => node.id)).toEqual(['draft-node']) }) - it('should keep the snippet canvas editable and sync draft changes without permission gating', async () => { + it('should keep the snippet canvas editable and sync draft changes with create-and-modify permission', async () => { const draftNode = createDraftNode('draft-node') - renderSnippetMain({ + const { store } = renderSnippetMain({ hasPublishedWorkflow: false, workflowDraftNodes: [draftNode], }) expect(screen.queryByRole('button', { name: 'edit' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'publish' })).toBeInTheDocument() + expect(store.getState().canvasReadOnly).toBe(false) expect(mockSetNavigationState).toHaveBeenCalledWith(expect.objectContaining({ readonly: false, })) @@ -315,6 +329,38 @@ describe('SnippetMain', () => { expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1) }) + it('should make the snippet canvas readonly and skip draft sync without create-and-modify permission', async () => { + mockAppContext.workspacePermissionKeys = ['snippets.management'] + + const { store } = renderSnippetMain({ + hasPublishedWorkflow: false, + workflowDraftNodes: [createDraftNode('draft-node')], + }) + + expect(screen.queryByRole('button', { name: 'publish' })).not.toBeInTheDocument() + expect(store.getState().canvasReadOnly).toBe(true) + expect(mockSetNavigationState).toHaveBeenCalledWith(expect.objectContaining({ + readonly: true, + })) + + const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise) + await doSyncWorkflowDraft() + const syncWorkflowDraftWhenPageClose = capturedHooksStore?.syncWorkflowDraftWhenPageClose as (() => void) + syncWorkflowDraftWhenPageClose() + snippetDetailStoreState.onFieldsChange?.([ + { + type: PipelineInputVarType.textInput, + label: 'Question', + variable: 'question', + required: false, + }, + ]) + + expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockSyncWorkflowDraftWhenPageClose).not.toHaveBeenCalled() + expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled() + }) + it('should render the draft graph even when a published workflow exists', async () => { const publishedNode = createDraftNode('published-node') const draftNode = createDraftNode('draft-node') diff --git a/web/app/components/snippets/components/snippet-children.tsx b/web/app/components/snippets/components/snippet-children.tsx index fb407038744..4c2e2db3545 100644 --- a/web/app/components/snippets/components/snippet-children.tsx +++ b/web/app/components/snippets/components/snippet-children.tsx @@ -8,6 +8,7 @@ type SnippetChildrenProps = { snippetId: string fields: SnippetInputField[] canSave: boolean + canEdit: boolean isPublishing: boolean onPublish: () => void } @@ -16,6 +17,7 @@ const SnippetChildren = ({ snippetId, fields, canSave, + canEdit, isPublishing, onPublish, }: SnippetChildrenProps) => { @@ -26,6 +28,7 @@ const SnippetChildren = ({ diff --git a/web/app/components/snippets/components/snippet-header/index.tsx b/web/app/components/snippets/components/snippet-header/index.tsx index a5e2aa0c5fe..95517eb58bc 100644 --- a/web/app/components/snippets/components/snippet-header/index.tsx +++ b/web/app/components/snippets/components/snippet-header/index.tsx @@ -13,6 +13,7 @@ import RunMode from './run-mode' type SnippetHeaderProps = { snippetId: string canSave: boolean + canEdit: boolean isPublishing: boolean onPublish: () => void } @@ -39,6 +40,7 @@ const PublishAction = ({ const SnippetHeader = ({ snippetId, canSave, + canEdit, isPublishing, onPublish, }: SnippetHeaderProps) => { @@ -54,11 +56,15 @@ const SnippetHeader = ({ normal: { components: { left: ( - + canEdit + ? ( + + ) + : null ), }, controls: { @@ -78,7 +84,7 @@ const SnippetHeader = ({ viewHistoryProps, }, } - }, [canSave, isPublishing, onPublish, t, viewHistoryProps]) + }, [canEdit, canSave, isPublishing, onPublish, t, viewHistoryProps]) return
} diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index 770d3e2e0c3..c712d3b35cd 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -23,6 +23,7 @@ import { initialEdges, initialNodes, } from '@/app/components/workflow/utils' +import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useSnippetDraftStore } from '../draft-store' import { useConfigsMap } from '../hooks/use-configs-map' import { useGetRunAndTraceUrl } from '../hooks/use-get-run-and-trace-url' @@ -32,6 +33,7 @@ import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft' import { useSnippetRun } from '../hooks/use-snippet-run' import { useSnippetStartRun } from '../hooks/use-snippet-start-run' import { useSnippetDetailStore } from '../store' +import { canCreateAndModifySnippets } from '../utils/permission' import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions' import { useSnippetPublish } from './hooks/use-snippet-publish' import SnippetChildren from './snippet-children' @@ -51,6 +53,7 @@ type SnippetMainContentProps = { snippetId: string fields: SnippetInputField[] canSave: boolean + canEdit: boolean onBeforePublish: () => Promise | void> onSaved: (syncedDraftPayload?: Omit | void) => void } @@ -80,6 +83,7 @@ const SnippetMainContent = ({ snippetId, fields, canSave, + canEdit, onBeforePublish, onSaved, }: SnippetMainContentProps) => { @@ -113,6 +117,7 @@ const SnippetMainContent = ({ snippetId={snippetId} fields={fields} canSave={canSave} + canEdit={canEdit} isPublishing={isPublishing} onPublish={handlePublishSnippet} /> @@ -138,7 +143,9 @@ const SnippetMain = ({ const effectiveDraftEdges = localDraftState?.edges ?? draftEdges const effectiveDraftViewport = localDraftState?.viewport ?? draftViewport const { graph, snippet } = effectiveDraftPayload - const canSave = currentCanvasNodeCount > 0 + const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const canEditSnippet = canCreateAndModifySnippets(workspacePermissionKeys) + const canSave = canEditSnippet && currentCanvasNodeCount > 0 const { doSyncWorkflowDraft: syncWorkflowDraft, syncWorkflowDraftWhenPageClose, @@ -210,7 +217,7 @@ const SnippetMain = ({ fields, handleFieldsChange: handleSnippetFieldsChange, } = useSnippetInputFieldActions({ - canEdit: true, + canEdit: canEditSnippet, snippetId, }) const { @@ -234,12 +241,12 @@ const SnippetMain = ({ }, [effectiveDraftPayload.inputFields, hydrateDraft, snippetId]) useEffect(() => { - workflowStore.setState({ canvasReadOnly: false }) + workflowStore.setState({ canvasReadOnly: !canEditSnippet }) return () => { workflowStore.setState({ canvasReadOnly: false }) } - }, [workflowStore]) + }, [canEditSnippet, workflowStore]) useEffect(() => { workflowStore.temporal.getState().pause() @@ -255,7 +262,19 @@ const SnippetMain = ({ const doSyncWorkflowDraft = useCallback(( ...args: Parameters - ) => syncWorkflowDraft(...args), [syncWorkflowDraft]) + ) => { + if (!canEditSnippet) + return Promise.resolve() + + return syncWorkflowDraft(...args) + }, [canEditSnippet, syncWorkflowDraft]) + + const handleSyncWorkflowDraftWhenPageClose = useCallback(() => { + if (!canEditSnippet) + return + + syncWorkflowDraftWhenPageClose() + }, [canEditSnippet, syncWorkflowDraftWhenPageClose]) const handleFieldsChange = useCallback((nextFields: SnippetInputField[]) => { handleSnippetFieldsChange(nextFields) @@ -265,10 +284,10 @@ const SnippetMain = ({ setNavigationState({ snippetId, snippet, - readonly: false, + readonly: !canEditSnippet, onFieldsChange: handleFieldsChange, }) - }, [handleFieldsChange, setNavigationState, snippet, snippetId]) + }, [canEditSnippet, handleFieldsChange, setNavigationState, snippet, snippetId]) const updateLocalDraftFromSyncPayload = useCallback(( syncedDraftPayload?: Omit | void, @@ -305,7 +324,7 @@ const SnippetMain = ({ const hooksStore = useMemo(() => { return { doSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose, + syncWorkflowDraftWhenPageClose: handleSyncWorkflowDraftWhenPageClose, handleRefreshWorkflowDraft, handleBackupDraft, handleLoadBackupDraft, @@ -331,11 +350,19 @@ const SnippetMain = ({ invalidateSysVarValues, resetConversationVar, invalidateConversationVarValues, + accessControl: { + canEdit: canEditSnippet, + canComment: true, + canRun: true, + canImportExportDSL: canEditSnippet, + canReleaseAndVersion: canEditSnippet, + }, configsMap, } }, [ appendNodeInspectVars, availableNodesMetaData, + canEditSnippet, configsMap, deleteAllInspectorVars, deleteInspectVar, @@ -345,6 +372,7 @@ const SnippetMain = ({ fetchInspectVarValue, fetchInspectVars, handleBackupDraft, + handleSyncWorkflowDraftWhenPageClose, handleRefreshWorkflowDraft, handleLoadBackupDraft, handleRestoreFromPublishedWorkflow, @@ -361,7 +389,6 @@ const SnippetMain = ({ renameInspectVarName, resetConversationVar, resetToLastRunVar, - syncWorkflowDraftWhenPageClose, ]) return ( @@ -378,7 +405,8 @@ const SnippetMain = ({ snippetId={snippetId} fields={fields} canSave={canSave} - onBeforePublish={() => syncWorkflowDraft(true)} + canEdit={canEditSnippet} + onBeforePublish={() => doSyncWorkflowDraft(true)} onSaved={updateLocalDraftFromSyncPayload} /> diff --git a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx index 01c58082401..ee67a561365 100644 --- a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx +++ b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx @@ -29,7 +29,7 @@ const { mockUseQueryData, createTag } = vi.hoisted(() => ({ })) const mockWorkspacePermissionKeys = vi.hoisted(() => ({ - value: ['app.tag.manage', 'dataset.tag.manage', 'snippets.management'] as string[], + value: ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify'] as string[], })) vi.mock('@tanstack/react-query', () => ({ @@ -99,7 +99,7 @@ describe('TagManagementModal', () => { beforeEach(() => { vi.clearAllMocks() mockUseQueryData.current = mockTags - mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.management'] + mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify'] vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 }) }) diff --git a/web/features/tag-management/__tests__/tag-search-content.spec.tsx b/web/features/tag-management/__tests__/tag-search-content.spec.tsx index 45aa3054c4e..ea67de7fdbc 100644 --- a/web/features/tag-management/__tests__/tag-search-content.spec.tsx +++ b/web/features/tag-management/__tests__/tag-search-content.spec.tsx @@ -216,8 +216,8 @@ describe('TagSearchContent', () => { expect(screen.getByRole('option', { name: /KnowledgeDB/i })).toBeInTheDocument() }) - it('renders snippet management action with snippets management permission', () => { - mockWorkspacePermissionKeys.value = ['snippets.management'] + it('renders snippet management action with snippets create-and-modify permission', () => { + mockWorkspacePermissionKeys.value = ['snippets.create_and_modify'] render( { }) const mockWorkspacePermissionKeys = vi.hoisted(() => ({ - value: ['app.tag.manage', 'dataset.tag.manage', 'snippets.management'] as string[], + value: ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify'] as string[], })) vi.mock('@/context/app-context', () => ({ @@ -110,7 +110,7 @@ const defaultProps = { describe('TagSelector', () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.management'] + mockWorkspacePermissionKeys.value = ['app.tag.manage', 'dataset.tag.manage', 'snippets.create_and_modify'] mockUseQueryData.current = appTags vi.mocked(createTag).mockResolvedValue({ id: 'new-tag', name: 'NewTag', type: 'app', binding_count: 0 }) vi.mocked(bindTag).mockResolvedValue(undefined) @@ -288,9 +288,9 @@ describe('TagSelector', () => { expect(createTag).not.toHaveBeenCalled() }) - it('opens snippet tag selector with snippets management permission', async () => { + it('opens snippet tag selector with snippets create-and-modify permission', async () => { const user = userEvent.setup() - mockWorkspacePermissionKeys.value = ['snippets.management'] + mockWorkspacePermissionKeys.value = ['snippets.create_and_modify'] mockUseQueryData.current = [{ id: 'snippet-tag-1', name: 'Reusable', type: 'snippet', binding_count: 1 }] render( diff --git a/web/features/tag-management/utils.ts b/web/features/tag-management/utils.ts index bc88569ade9..08c7e22a8f9 100644 --- a/web/features/tag-management/utils.ts +++ b/web/features/tag-management/utils.ts @@ -7,7 +7,7 @@ export const getTagManagePermissionKey = (type: TagType): PermissionKey => { return 'app.tag.manage' if (type === 'snippet') - return SnippetPermission.Management + return SnippetPermission.CreateAndModify return 'dataset.tag.manage' }