From a87e396b02a49834498c93c36a1a89645306f236 Mon Sep 17 00:00:00 2001 From: fatelei Date: Tue, 11 Aug 2026 11:53:43 +0800 Subject: [PATCH 1/3] feat: add permission ui check --- api/services/enterprise/rbac_service.py | 13 ++ .../skills/__tests__/detail-page.spec.tsx | 4 + web/features/skills/__tests__/page.spec.tsx | 4 + web/features/skills/detail/file-editor.tsx | 3 + web/features/skills/detail/file-tree.tsx | 20 ++- web/features/skills/detail/page.tsx | 13 +- web/features/skills/detail/publish-bar.tsx | 38 ++-- .../skills/detail/sidebar-actions.tsx | 52 +++--- web/features/skills/page.tsx | 168 +++++++++++------- 9 files changed, 203 insertions(+), 112 deletions(-) diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index 5bfefd8a916..c4f9b79df5e 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -304,6 +304,10 @@ class MyPermissionsResponse(_RBACModel): # Fallback permission snapshots for legacy Dify tenant roles when external RBAC is disabled. # Keep these keys aligned with langgenius/rbac's built-in workspace roles and access policies. _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [ + "skill.view", + "skill.edit", + "skill.publish", + "skill.delete", "workspace.member.manage", "workspace.role.manage", "data_source.manage", @@ -334,6 +338,10 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [ ] _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ + "skill.view", + "skill.edit", + "skill.publish", + "skill.delete", "workspace.member.manage", "workspace.role.manage", "data_source.manage", @@ -362,6 +370,9 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ ] _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ + "skill.view", + "skill.edit", + "skill.delete", "api_extension.manage", "plugin.install", "credential.use", @@ -377,6 +388,7 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ ] _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ + "skill.view", "api_extension.manage", "plugin.install", "credential.use", @@ -384,6 +396,7 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ ] _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ + "skill.view", "plugin.install", "dataset.create_and_management", "dataset.external.connect", diff --git a/web/features/skills/__tests__/detail-page.spec.tsx b/web/features/skills/__tests__/detail-page.spec.tsx index 0bbd0471600..63e58397b32 100644 --- a/web/features/skills/__tests__/detail-page.spec.tsx +++ b/web/features/skills/__tests__/detail-page.spec.tsx @@ -261,6 +261,10 @@ vi.mock('@/service/client', () => ({ }, })) +vi.mock('../permissions', () => ({ + useSkillPermissions: () => ({ canDelete: true, canEdit: true, canPublish: true }), +})) + vi.mock('../client', () => ({ fetchSkillArchiveBlob: mocks.fetchSkillArchiveBlob, fetchSkillFileBlob: mocks.fetchSkillFileBlob, diff --git a/web/features/skills/__tests__/page.spec.tsx b/web/features/skills/__tests__/page.spec.tsx index 0005f732605..6c2b50c826c 100644 --- a/web/features/skills/__tests__/page.spec.tsx +++ b/web/features/skills/__tests__/page.spec.tsx @@ -195,6 +195,10 @@ vi.mock('@/service/client', () => ({ }, })) +vi.mock('../permissions', () => ({ + useSkillPermissions: () => ({ canDelete: true, canEdit: true, canPublish: true }), +})) + function createSkill(overrides: Partial = {}): SkillResponse { return { id: 'skill-1', diff --git a/web/features/skills/detail/file-editor.tsx b/web/features/skills/detail/file-editor.tsx index c850c942d62..52a7e24f7a0 100644 --- a/web/features/skills/detail/file-editor.tsx +++ b/web/features/skills/detail/file-editor.tsx @@ -88,6 +88,7 @@ const SkillPdfPreview = dynamic( ) export function FileEditor({ + canPublish, detail, file, fileMutationCoordinator, @@ -112,6 +113,7 @@ export function FileEditor({ selectedVersionId, skillId, }: { + canPublish: boolean detail: SkillDetailResponse | undefined file: SkillFileResponse | undefined fileMutationCoordinator: SkillFileMutationCoordinator @@ -1499,6 +1501,7 @@ export function FileEditor({ hidden={publishConfirmOpen} > - {!readonly && detail && ( - setSkillRenameEditing(true)} - /> - )} + {!readonly && + detail && + (canEdit || canDelete || !!detail.latest_published_version_id) && ( + setSkillRenameEditing(true)} + /> + )} () const [openFilePaths, setOpenFilePaths] = useState([]) const [previewFilePath, setPreviewFilePath] = useState() @@ -357,17 +359,20 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
setRightPanelMode('builder') : undefined + canEdit && rightPanelMode === 'hidden' ? () => setRightPanelMode('builder') : undefined } onOpenVersions={handleOpenVersions} onPublish={handlePublish} @@ -392,7 +397,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { openFiles={openFiles} previewFilePath={previewFilePath} publishing={activeVersionId ? restoreMutation.isPending : publishMutation.isPending} - readonly={!!activeVersionId} + readonly={!!activeVersionId || !canEdit} selectedPath={activeSelectedPath} selectedVersion={selectedVersion} selectedVersionId={activeVersionId} @@ -439,7 +444,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
)} - {rightPanelMode === 'builder' && ( + {canEdit && rightPanelMode === 'builder' && ( void @@ -45,6 +46,7 @@ export function SkillPublishShortcut() { } export function SkillPublishBar({ + canPublish: hasPublishPermission = true, children, metaLabel, onOpenVersions, @@ -52,7 +54,7 @@ export function SkillPublishBar({ state, }: SkillPublishBarProps) { const { t } = useTranslation('skill') - const canPublish = state === 'draft' || state === 'unpublished' + const canPublish = hasPublishPermission && (state === 'draft' || state === 'unpublished') useHotkey(PUBLISH_SKILL_HOTKEY, onPublish, { enabled: canPublish, @@ -121,21 +123,25 @@ export function SkillPublishBar({ > - + {hasPublishPermission && ( + + )} ) } diff --git a/web/features/skills/detail/sidebar-actions.tsx b/web/features/skills/detail/sidebar-actions.tsx index 5c4d1f2bc81..3b9d383dbf2 100644 --- a/web/features/skills/detail/sidebar-actions.tsx +++ b/web/features/skills/detail/sidebar-actions.tsx @@ -192,9 +192,13 @@ function SkillDetailDeleteDialog({ } export function SkillDetailSidebarActions({ + canDelete, + canEdit, detail, onRename, }: { + canDelete: boolean + canEdit: boolean detail: SkillDetailResponse onRename: () => void }) { @@ -248,17 +252,21 @@ export function SkillDetailSidebarActions({ - - - {tCommon(($) => $['operation.rename'])} - - - - {tCommon(($) => $['operation.duplicate'])} - + {canEdit && ( + + + {tCommon(($) => $['operation.rename'])} + + )} + {canEdit && ( + + + {tCommon(($) => $['operation.duplicate'])} + + )} {detail.latest_published_version_id && ( {tCommon(($) => $['operation.export'])} )} - - setDeleteOpen(true)} - > - - {tCommon(($) => $['operation.delete'])} - + {canDelete && ( + <> + + setDeleteOpen(true)} + > + + {tCommon(($) => $['operation.delete'])} + + + )} diff --git a/web/features/skills/page.tsx b/web/features/skills/page.tsx index 6739670b640..d1606981522 100644 --- a/web/features/skills/page.tsx +++ b/web/features/skills/page.tsx @@ -47,6 +47,7 @@ import { consoleQuery } from '@/service/client' import { downloadBlob } from '@/utils/download' import { fetchSkillArchiveBlob } from './client' import { SkillReferencesList, SkillReferencesListSkeleton } from './detail/skill-metadata' +import { useSkillPermissions } from './permissions' import { skillKeywordQueryParser, skillQueryParamNames, skillTagQueryParser } from './query-params' import { SkillListTagManagementModal } from './skill-list-tag-management-modal' @@ -107,6 +108,7 @@ function SkillCardSkeleton() { } function SkillPlaceholderState({ + canEdit, creating, importing, isEmptySearch, @@ -114,6 +116,7 @@ function SkillPlaceholderState({ onImport, title, }: { + canEdit?: boolean creating?: boolean importing?: boolean isEmptySearch?: boolean @@ -152,7 +155,7 @@ function SkillPlaceholderState({ {title} - {!isEmptySearch && ( + {!isEmptySearch && canEdit !== false && (
)} -
- - $['skillManagement.moreActions'], { name: skill.display_name })} - className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" - onClick={(event) => event.stopPropagation()} - > - - {t(($) => $['skillManagement.moreActions'], { name: skill.display_name })} - - - - - - - {tCommon(($) => $['operation.duplicate'])} - - {skill.latest_published_version_id && ( - - - {tCommon(($) => $['operation.export'])} - - )} - - setIsDeleteOpen(true)} + {(canEdit || canDelete || !!skill.latest_published_version_id) && ( +
+ + $['skillManagement.moreActions'], { name: skill.display_name })} + className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" + onClick={(event) => event.stopPropagation()} > - - {tCommon(($) => $['operation.delete'])} - - - -
+ + {t(($) => $['skillManagement.moreActions'], { name: skill.display_name })} + + + + + {canEdit && ( + + + {tCommon(($) => $['operation.duplicate'])} + + )} + {skill.latest_published_version_id && ( + + + {tCommon(($) => $['operation.export'])} + + )} + {canDelete && ( + <> + + setIsDeleteOpen(true)} + > + + {tCommon(($) => $['operation.delete'])} + + + )} + +
+
+ )} ) @@ -565,12 +580,14 @@ function SkillTagFilter({ onOpenTagManagement }: { onOpenTagManagement: () => vo } function SkillsToolbar({ + canEdit, creating, importing, onCreate, onImport, onOpenTagManagement, }: { + canEdit: boolean creating: boolean importing: boolean onCreate: () => void @@ -594,31 +611,37 @@ function SkillsToolbar({ }} />
- - + {canEdit && ( + + )} + {canEdit && ( + + )}
) } function SkillGrid({ + canDelete, + canEdit, creating, importing, isEmptySearch, @@ -631,6 +654,8 @@ function SkillGrid({ onOpenTagManagement, skills, }: { + canDelete: boolean + canEdit: boolean creating: boolean importing: boolean isEmptySearch: boolean @@ -657,6 +682,7 @@ function SkillGrid({ )} {!isPending && !isError && skills.length === 0 && ( ( - + ))} {!isPending && !isError && isFetchingNextPage && } @@ -683,6 +715,7 @@ export default function SkillsPage() { const { t } = useTranslation('skill') const router = useRouter() const queryClient = useQueryClient() + const { canDelete, canEdit } = useSkillPermissions() const importInputRef = useRef(null) const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [keyword] = useQueryState(skillQueryParamNames.keyword, skillKeywordQueryParser) @@ -780,6 +813,7 @@ export default function SkillsPage() { onChange={(event) => handleFileChange(event.currentTarget.files?.[0])} /> Date: Tue, 11 Aug 2026 11:57:09 +0800 Subject: [PATCH 2/3] fix: fix scroll --- web/features/skills/page.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/web/features/skills/page.tsx b/web/features/skills/page.tsx index d1606981522..0c8ad936e80 100644 --- a/web/features/skills/page.tsx +++ b/web/features/skills/page.tsx @@ -33,7 +33,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useDebounce } from 'ahooks' import { useQueryState } from 'nuqs' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import { SkeletonRectangle } from '@/app/components/base/skeleton' @@ -717,6 +717,7 @@ export default function SkillsPage() { const queryClient = useQueryClient() const { canDelete, canEdit } = useSkillPermissions() const importInputRef = useRef(null) + const listViewportRef = useRef(null) const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [keyword] = useQueryState(skillQueryParamNames.keyword, skillKeywordQueryParser) const [selectedTags] = useQueryState(skillQueryParamNames.tag, skillTagQueryParser) @@ -741,6 +742,7 @@ export default function SkillsPage() { refetchOnMount: 'always', }) const skills = skillsQuery.data?.pages.flatMap((page) => page.data ?? []) ?? [] + const { fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = skillsQuery useDocumentTitle(t(($) => $['skillManagement.title'])) @@ -792,10 +794,17 @@ export default function SkillsPage() { const handleListScroll = (event: UIEvent) => { const target = event.currentTarget const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight - if (scrollBottom < 80 && skillsQuery.hasNextPage && !skillsQuery.isFetchingNextPage) - void skillsQuery.fetchNextPage() + if (scrollBottom < 80 && hasNextPage && !isFetchingNextPage) void fetchNextPage() } + useEffect(() => { + const viewport = listViewportRef.current + if (!viewport || viewport.clientHeight === 0 || isPending || isFetchingNextPage || !hasNextPage) + return + + if (viewport.scrollHeight - viewport.clientHeight < 80) void fetchNextPage() + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isPending, skills.length]) + return (
@@ -826,6 +835,7 @@ export default function SkillsPage() {
Date: Tue, 11 Aug 2026 12:15:11 +0800 Subject: [PATCH 3/3] chore: add miss file --- web/features/skills/permissions.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 web/features/skills/permissions.ts diff --git a/web/features/skills/permissions.ts b/web/features/skills/permissions.ts new file mode 100644 index 00000000000..44f90069006 --- /dev/null +++ b/web/features/skills/permissions.ts @@ -0,0 +1,19 @@ +import { useAtomValue } from 'jotai' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { hasPermission } from '@/utils/permission' + +export const SkillPermission = { + Edit: 'skill.edit', + Publish: 'skill.publish', + Delete: 'skill.delete', +} as const + +export function useSkillPermissions() { + const permissionKeys = useAtomValue(workspacePermissionKeysAtom) + + return { + canEdit: hasPermission(permissionKeys, SkillPermission.Edit), + canPublish: hasPermission(permissionKeys, SkillPermission.Publish), + canDelete: hasPermission(permissionKeys, SkillPermission.Delete), + } +}