feat(web): align snippet permission controls

This commit is contained in:
JzoNg 2026-06-24 17:01:26 +08:00
parent f2c07194f8
commit 433dca0acd
13 changed files with 153 additions and 60 deletions

View File

@ -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(<SnippetInfoDropdown snippet={mockSnippet} />)
@ -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(<SnippetInfoDropdown snippet={mockSnippet} />)

View File

@ -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 && (
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
)}
{canManageSnippet && (
<>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
</>
)}
{canManageSnippet && (
<>
{canCreateAndModifySnippet && <DropdownMenuSeparator className="my-1! bg-divider-subtle" />}
<DropdownMenuItem
className="mx-0 gap-2"
variant="destructive"

View File

@ -359,6 +359,18 @@ describe('SnippetList', () => {
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([])

View File

@ -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(<SnippetCard snippet={createSnippet()} />)
@ -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(<SnippetCard snippet={createSnippet()} />)

View File

@ -82,7 +82,7 @@ const SnippetCard = ({
}
const handleExportSnippet = async () => {
if (!canManageSnippet)
if (!canCreateAndModifySnippet)
return
setIsOperationsMenuOpen(false)
@ -155,13 +155,7 @@ const SnippetCard = ({
</Link>
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-1.5 pb-1.5 pl-3.5">
<div
className="flex w-0 grow items-center gap-1"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex w-0 grow items-center gap-1">
<div className="mr-10.25 min-w-0 grow overflow-hidden">
<TagSelector
placement="bottom-start"
@ -203,16 +197,18 @@ const SnippetCard = ({
popupClassName="w-[216px]"
>
{canCreateAndModifySnippet && (
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}>
<span className="system-sm-regular text-text-secondary">{t('menu.editInfo')}</span>
</DropdownMenuItem>
)}
{canManageSnippet && (
<>
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}>
<span className="system-sm-regular text-text-secondary">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 px-3" onClick={handleExportSnippet}>
<span className="system-sm-regular text-text-secondary">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{canManageSnippet && (
<>
{canCreateAndModifySnippet && <DropdownMenuSeparator />}
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"

View File

@ -25,6 +25,9 @@ const mockHandleStopRun = vi.fn()
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleCheckBeforePublish = vi.fn()
const mockUseAvailableNodesMetaData = vi.hoisted(() => 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: <T,>(selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({
workspacePermissionKeys: mockAppContext.workspacePermissionKeys,
}),
}))
let capturedHooksStore: Record<string, unknown> | 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
}) => (
<div>
<a href="/snippets">snippets list</a>
<button type="button" disabled={!canSave} onClick={onPublish}>publish</button>
{canEdit && <button type="button" disabled={!canSave} onClick={onPublish}>publish</button>}
</div>
),
}))
@ -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<void>)
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')

View File

@ -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 = ({
<SnippetHeader
snippetId={snippetId}
canSave={canSave}
canEdit={canEdit}
isPublishing={isPublishing}
onPublish={onPublish}
/>

View File

@ -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: (
<PublishAction
canSave={canSave}
isPublishing={isPublishing}
onPublish={onPublish}
/>
canEdit
? (
<PublishAction
canSave={canSave}
isPublishing={isPublishing}
onPublish={onPublish}
/>
)
: null
),
},
controls: {
@ -78,7 +84,7 @@ const SnippetHeader = ({
viewHistoryProps,
},
}
}, [canSave, isPublishing, onPublish, t, viewHistoryProps])
}, [canEdit, canSave, isPublishing, onPublish, t, viewHistoryProps])
return <Header {...headerProps} />
}

View File

@ -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<Omit<SnippetDraftSyncPayload, 'hash'> | void>
onSaved: (syncedDraftPayload?: Omit<SnippetDraftSyncPayload, 'hash'> | 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<typeof syncWorkflowDraft>
) => 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<SnippetDraftSyncPayload, 'hash'> | 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}
/>
</WorkflowWithInnerContext>

View File

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

View File

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

View File

@ -30,7 +30,7 @@ const { mockUseQueryData, createTag, bindTag, unBindTag } = 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('@/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(

View File

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