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