feat: add app and dataset access rule sections with hooks for managing access policies

This commit is contained in:
twwu 2026-05-09 18:17:10 +08:00
parent 212252bb78
commit f7807c532d
17 changed files with 524 additions and 490 deletions

View File

@ -1,5 +1,6 @@
'use client'
import type { AccessPolicy } from '@/models/access-control'
import {
DropdownMenu,
DropdownMenuContent,
@ -7,22 +8,43 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import { useCopyAccessRule, useDeleteAccessRule } from '@/service/access-control/use-workspace-access-rules'
export type AccessRuleRowMenuProps = {
rule: AccessPolicy
onEdit?: () => void
onCopy?: () => void
onDelete?: () => void
}
const AccessRuleRowMenu = ({
rule,
onEdit,
onCopy,
onDelete,
}: AccessRuleRowMenuProps) => {
const [open, setOpen] = useState(false)
const { mutateAsync: copyAccessRule } = useCopyAccessRule(rule.resource_type)
const { mutateAsync: deleteAccessRule } = useDeleteAccessRule(rule.resource_type)
const handleCopyRules = useCallback(() => {
copyAccessRule(rule.id, {
onSuccess: () => {
toast.success('Access rule copied successfully')
setOpen(false)
},
})
}, [copyAccessRule, rule.id])
const handleDelete = useCallback(() => {
deleteAccessRule(rule.id, {
onSuccess: () => {
toast.success('Access rule deleted successfully')
setOpen(false)
},
})
}, [deleteAccessRule, rule.id])
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
@ -49,7 +71,7 @@ const AccessRuleRowMenu = ({
</DropdownMenuItem>
<DropdownMenuItem
className="system-sm-semibold text-text-secondary"
onClick={onCopy}
onClick={handleCopyRules}
>
Copy
</DropdownMenuItem>
@ -57,7 +79,7 @@ const AccessRuleRowMenu = ({
<DropdownMenuItem
variant="destructive"
className="system-sm-semibold"
onClick={onDelete}
onClick={handleDelete}
>
Delete
</DropdownMenuItem>

View File

@ -1,32 +1,19 @@
'use client'
import type { AccessPolicyWithBindings } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { memo, useCallback } from 'react'
import { useUpdateAppAccessRuleBindings, useUpdateDatasetAccessRuleBindings } from '@/service/access-control/use-workspace-access-rules'
import AccessRuleRowMenu from './access-rule-row-menu'
import RoleTag from './role-tag'
export type AssignedRole = {
id: string
name: string
}
export type AccessRule = {
id: string
name: string
description: string
assignedRoles: AssignedRole[]
permissions: string[]
}
export type AccessRuleRowProps = {
rule: AccessRule
rule: AccessPolicyWithBindings
className?: string
showMenu?: boolean
onEdit?: (rule: AccessRule) => void
onCopy?: (rule: AccessRule) => void
onDelete?: (rule: AccessRule) => void
onAddRole?: (rule: AccessRule) => void
onRemoveRole?: (rule: AccessRule, role: AssignedRole) => void
onEdit?: (rule: AccessPolicyWithBindings) => void
onAddRole?: (rule: AccessPolicyWithBindings) => void
}
const AccessRuleRow = ({
@ -34,38 +21,61 @@ const AccessRuleRow = ({
className,
showMenu = true,
onEdit,
onCopy,
onDelete,
onAddRole,
onRemoveRole,
}: AccessRuleRowProps) => {
const { policy, role_ids } = rule
const handleEdit = useCallback(() => onEdit?.(rule), [onEdit, rule])
const handleCopy = useCallback(() => onCopy?.(rule), [onCopy, rule])
const handleDelete = useCallback(() => onDelete?.(rule), [onDelete, rule])
const handleAddRole = useCallback(() => onAddRole?.(rule), [onAddRole, rule])
const { mutateAsync: updateAppAccessRuleBindings } = useUpdateAppAccessRuleBindings()
const { mutateAsync: updateDatasetAccessRuleBindings } = useUpdateDatasetAccessRuleBindings()
const handleRemoveRole = useCallback((roleId: string) => {
const payload = {
id: policy.id,
role_ids: role_ids.filter(id => id !== roleId),
account_ids: [],
}
if (policy.resource_type === 'app') {
updateAppAccessRuleBindings(payload, {
onSuccess: () => {
toast.success('Access rule updated successfully')
},
})
}
else if (policy.resource_type === 'dataset') {
updateDatasetAccessRuleBindings(payload, {
onSuccess: () => {
toast.success('Access rule updated successfully')
},
})
}
}, [policy.id, policy.resource_type, role_ids, updateAppAccessRuleBindings, updateDatasetAccessRuleBindings])
return (
<div className={cn('flex items-start gap-2 py-3.5', className)}>
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{rule.name}
{policy.name}
</div>
<p className="mt-0.5 system-xs-regular text-text-tertiary">
{rule.description}
{policy.description}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{rule.assignedRoles.map(role => (
{role_ids.map(role => (
<RoleTag
key={role.id}
label={role.name}
onRemove={onRemoveRole ? () => onRemoveRole(rule, role) : undefined}
key={role}
id={role}
label={role}
onRemove={handleRemoveRole}
/>
))}
<button
type="button"
onClick={handleAddRole}
className="inline-flex h-6 items-center gap-0.5 rounded-md border border-divider-deep px-1.5 system-xs-medium text-text-tertiary hover:border-divider-solid hover:text-text-secondary"
aria-label={`Add role to ${rule.name}`}
aria-label={`Add role to ${policy.name}`}
>
<span aria-hidden className="i-ri-add-line h-3 w-3" />
Add
@ -75,8 +85,7 @@ const AccessRuleRow = ({
{showMenu && (
<AccessRuleRowMenu
onEdit={handleEdit}
onCopy={handleCopy}
onDelete={handleDelete}
rule={policy}
/>
)}
</div>

View File

@ -1,21 +1,18 @@
'use client'
import type { AccessRule, AssignedRole } from './access-rule-row'
import type { AccessPolicyWithBindings } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
import AccessRuleRow from './access-rule-row'
export type AccessRuleSectionProps = {
type AccessRuleSectionProps = {
title: string
rules: AccessRule[]
rules: AccessPolicyWithBindings[]
createButtonLabel: string
onCreate?: () => void
onEditRule?: (rule: AccessRule) => void
onCopyRule?: (rule: AccessRule) => void
onDeleteRule?: (rule: AccessRule) => void
onAddRole?: (rule: AccessRule) => void
onRemoveRole?: (rule: AccessRule, role: AssignedRole) => void
onEditRule?: (rule: AccessPolicyWithBindings) => void
onAddRole?: (rule: AccessPolicyWithBindings) => void
className?: string
}
@ -25,10 +22,7 @@ const AccessRuleSection = ({
createButtonLabel,
onCreate,
onEditRule,
onCopyRule,
onDeleteRule,
onAddRole,
onRemoveRole,
className,
}: AccessRuleSectionProps) => {
return (
@ -44,14 +38,11 @@ const AccessRuleSection = ({
<div className="overflow-hidden">
{rules.map((rule, index) => (
<AccessRuleRow
key={rule.id}
key={rule.policy.id}
rule={rule}
className={cn(index > 0 && 'border-t border-divider-subtle')}
onEdit={onEditRule}
onCopy={onCopyRule}
onDelete={onDeleteRule}
onAddRole={onAddRole}
onRemoveRole={onRemoveRole}
/>
))}
</div>

View File

@ -15,14 +15,9 @@ import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useCallback, useMemo, useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
import { useMembers } from '@/service/use-common'
export type AssignableRoleOption = {
id: string
name: string
description?: string
}
export type AssignableMemberOption = {
id: string
name: string
@ -49,15 +44,6 @@ const TABS: Array<{ key: TabKey, label: string }> = [
{ key: 'members', label: 'MEMBERS' },
]
// TODO: replace with roles fetched from the permissions API once available.
const MOCK_ROLE_OPTIONS: AssignableRoleOption[] = [
{ id: 'admin', name: 'Admin', description: 'Full workspace management' },
{ id: 'editor', name: 'Editor', description: 'Create and edit resources' },
{ id: 'member', name: 'Member', description: 'Basic access' },
{ id: 'auditor', name: 'Auditor', description: 'View logs and audit trails' },
{ id: 'tester', name: 'Tester', description: 'Test in sandbox' },
]
const toMemberOption = (member: Member): AssignableMemberOption => ({
id: member.id,
name: member.name,
@ -72,9 +58,19 @@ const AddRuleTargetsModalBody = ({
onClose,
onSubmit,
}: AddRuleTargetsModalBaseProps) => {
const { data: membersData, isLoading: membersLoading } = useMembers()
const [activeTab, setActiveTab] = useState<TabKey>('roles')
const [keyword, setKeyword] = useState('')
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>(initialRoleIds)
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>(initialMemberIds)
const roles = MOCK_ROLE_OPTIONS
const { data: rolesData, isLoading: rolesLoading } = useWorkspaceRoleList({
page: 1,
limit: 20,
})
const roles = useMemo(() => rolesData?.data ?? [], [rolesData])
const { data: membersData, isLoading: membersLoading } = useMembers()
const members = useMemo<AssignableMemberOption[]>(() => {
const accounts = membersData?.accounts ?? []
@ -82,10 +78,6 @@ const AddRuleTargetsModalBody = ({
.filter(account => account.status !== 'banned' && account.status !== 'closed')
.map(toMemberOption)
}, [membersData])
const [activeTab, setActiveTab] = useState<TabKey>('roles')
const [keyword, setKeyword] = useState('')
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>(initialRoleIds)
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>(initialMemberIds)
const trimmed = keyword.trim().toLowerCase()
@ -202,55 +194,61 @@ const AddRuleTargetsModalBody = ({
slotClassNames={{ viewport: 'px-3 overscroll-contain' }}
>
{activeTab === 'roles' && (
filteredRoles.length === 0
rolesLoading
? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
No matching roles
Loading roles...
</div>
)
: (
<ul className="flex flex-col gap-0.5 pb-2">
{filteredRoles.map((role) => {
const checked = selectedRoleIds.includes(role.id)
const handleToggle = () => toggleRole(role.id)
return (
<li key={role.id}>
<div
role="checkbox"
aria-checked={checked}
tabIndex={0}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active',
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
)}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
handleToggle()
}
}}
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{role.name}
</div>
{role.description && (
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{role.description}
</div>
: filteredRoles.length === 0
? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
No matching roles
</div>
)
: (
<ul className="flex flex-col gap-0.5 pb-2">
{filteredRoles.map((role) => {
const checked = selectedRoleIds.includes(role.id)
const handleToggle = () => toggleRole(role.id)
return (
<li key={role.id}>
<div
role="checkbox"
aria-checked={checked}
tabIndex={0}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active',
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
)}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
handleToggle()
}
}}
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{role.name}
</div>
{role.description && (
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{role.description}
</div>
)}
</div>
</div>
</div>
</li>
)
})}
</ul>
)
</li>
)
})}
</ul>
)
)}
{activeTab === 'members' && (
membersLoading

View File

@ -0,0 +1,38 @@
import type { AccessPolicyWithBindings } from '@/models/access-control'
import { useWorkspaceAppAccessRules } from '@/service/access-control/use-workspace-access-rules'
import AccessRuleSection from './access-rule-section'
type AppAccessRuleSectionProps = {
className?: string
onCreate?: () => void
onEditRule?: (rule: AccessPolicyWithBindings) => void
onAddRole?: (rule: AccessPolicyWithBindings) => void
}
const AppAccessRuleSection = ({
className,
onCreate,
onEditRule,
onAddRole,
}: AppAccessRuleSectionProps) => {
const { data: appAccessRulesResponse } = useWorkspaceAppAccessRules({
page: 1,
limit: 20,
})
const appAccessRules = appAccessRulesResponse?.items || []
return (
<AccessRuleSection
title="App Access Rules"
rules={appAccessRules}
createButtonLabel="Create App permission set"
onCreate={onCreate}
onEditRule={onEditRule}
onAddRole={onAddRole}
className={className}
/>
)
}
export default AppAccessRuleSection

View File

@ -0,0 +1,38 @@
import type { AccessPolicyWithBindings } from '@/models/access-control'
import { useWorkspaceDatasetAccessRules } from '@/service/access-control/use-workspace-access-rules'
import AccessRuleSection from './access-rule-section'
type DatasetAccessRuleSectionProps = {
className?: string
onCreate?: () => void
onEditRule?: (rule: AccessPolicyWithBindings) => void
onAddRole?: (rule: AccessPolicyWithBindings) => void
}
const DatasetAccessRuleSection = ({
className,
onCreate,
onEditRule,
onAddRole,
}: DatasetAccessRuleSectionProps) => {
const { data: datasetAccessRulesResponse } = useWorkspaceDatasetAccessRules({
page: 1,
limit: 20,
})
const datasetAccessRules = datasetAccessRulesResponse?.items || []
return (
<AccessRuleSection
title="Knowledge Base Access Rules"
rules={datasetAccessRules}
createButtonLabel="Create KB permission set"
onCreate={onCreate}
onEditRule={onEditRule}
onAddRole={onAddRole}
className={className}
/>
)
}
export default DatasetAccessRuleSection

View File

@ -1,149 +1,23 @@
'use client'
import type { AccessRule } from './access-rule-row'
import type { PermissionSetFormValues, PermissionSetModalMode } from './permission-set-modal'
import type { ResourceType } from './permission-set-modal/permissions-data'
import type { AccessPolicyResourceType, AccessPolicyWithBindings } from '@/models/access-control'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import AccessRuleSection from './access-rule-section'
import { useCreateAccessRule, useUpdateAppAccessRuleBindings, useUpdateDatasetAccessRuleBindings } from '@/service/access-control/use-workspace-access-rules'
import AddRuleTargetsModal from './add-rule-targets-modal'
import AppAccessRuleSection from './app-access-rule-section'
import DatasetAccessRuleSection from './dataset-access-rule-section'
import PermissionSetModal from './permission-set-modal'
const APP_ACCESS_RULES: AccessRule[] = [
{
id: 'app-full-access',
name: 'Full access',
description: 'Highest level. Can edit, publish, delete apps, and manage access for this app.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [
'app.editing_and_layout',
'app.test_and_debug',
'app.delete',
'app.import_export_dsl',
'app.release_version_management',
'app.annotation_management',
'app.api_management.toggle',
'app.api_management.create_key',
'app.api_management.delete_key',
],
},
{
id: 'app-can-edit',
name: 'Can edit',
description: 'Modify Prompts, adjust workflows, change variables. Test and publish updates.',
assignedRoles: [
{ id: 'app-editor', name: 'App Editor' },
{ id: 'it-staff', name: 'IT Staff' },
],
permissions: [
'app.editing_and_layout',
'app.test_and_debug',
'app.release_version_management',
],
},
{
id: 'app-can-view-and-use',
name: 'Can view & use',
description: 'View and use the app. Access Prompt and workflow logs. Cannot modify.',
assignedRoles: [
{ id: 'tester', name: 'Tester' },
{ id: 'ops-staff', name: 'Ops Staff' },
{ id: 'member', name: 'Member' },
],
permissions: [
'app.test_and_debug',
],
},
{
id: 'app-can-preview',
name: 'Can preview',
description: 'View the app in the list only. Cannot open the editor or use the app.',
assignedRoles: [
{ id: 'partner', name: 'Partner' },
],
permissions: [],
},
]
const KNOWLEDGE_BASE_ACCESS_RULES: AccessRule[] = [
{
id: 'kb-full-access',
name: 'Full access',
description: 'Highest level. Can edit, publish, delete apps, and manage access for this knowledge base.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [
'kb.view',
'kb.edit_configuration',
'kb.manage_documents.add',
'kb.manage_documents.delete',
'kb.manage_documents.download',
'kb.import_export_pipeline',
'kb.pipeline_publishing_versioning',
'kb.delete',
],
},
{
id: 'kb-can-edit',
name: 'Can edit',
description: 'Edit knowledge base content, modify settings, and run tests.',
assignedRoles: [
{ id: 'kb-editor', name: 'KB Editor' },
{ id: 'ops-staff', name: 'Ops Staff' },
{ id: 'it-staff', name: 'IT Staff' },
],
permissions: [
'kb.edit_configuration',
'kb.manage_documents.add',
'kb.manage_documents.delete',
'kb.manage_documents.download',
],
},
{
id: 'kb-can-view',
name: 'Can view',
description: 'View knowledge base sources and logs. Cannot modify content.',
assignedRoles: [
{ id: 'member', name: 'Member' },
],
permissions: ['kb.view'],
},
{
id: 'kb-can-preview',
name: 'Can preview',
description: 'View in the list only. Cannot access the detail page.',
assignedRoles: [
{ id: 'partner', name: 'Partner' },
],
permissions: [],
},
{
id: 'kb-can-test',
name: 'Can test',
description: 'Test knowledge base retrieval efficiency in sandbox.',
assignedRoles: [
{ id: 'tester', name: 'Tester' },
],
permissions: ['kb.view'],
},
]
type PermissionSetModalState = {
mode: PermissionSetModalMode
resourceType: ResourceType
resourceType: AccessPolicyResourceType
initialValues?: PermissionSetFormValues
}
const AccessRulesPage = () => {
const [addingRule, setAddingRule] = useState<AccessRule | null>(null)
const [addingRule, setAddingRule] = useState<AccessPolicyWithBindings | null>(null)
const [permissionSetModalState, setPermissionSetModalState]
= useState<PermissionSetModalState | null>(null)
@ -155,89 +29,110 @@ const AccessRulesPage = () => {
setPermissionSetModalState(null)
}, [])
const handleAddRole = useCallback((rule: AccessRule) => {
const handleAddRole = useCallback((rule: AccessPolicyWithBindings) => {
setAddingRule(rule)
}, [])
const { mutateAsync: updateAppAccessRuleBindings } = useUpdateAppAccessRuleBindings()
const { mutateAsync: updateDatasetAccessRuleBindings } = useUpdateDatasetAccessRuleBindings()
const handleAddSubmit = useCallback(
(_selection: { roleIds: string[], memberIds: string[] }) => {
// TODO: wire up to API when backend is ready.
(selection: { roleIds: string[], memberIds: string[] }) => {
const { id, resource_type } = addingRule!.policy
const payload = {
id,
role_ids: selection.roleIds,
account_ids: selection.memberIds,
}
if (resource_type === 'app') {
updateAppAccessRuleBindings(payload, {
onSuccess: () => {
toast.success('Access rule updated successfully')
closeAddModal()
},
})
}
else if (resource_type === 'dataset') {
updateDatasetAccessRuleBindings(payload, {
onSuccess: () => {
toast.success('Access rule updated successfully')
closeAddModal()
},
})
}
},
[],
[addingRule, closeAddModal, updateAppAccessRuleBindings, updateDatasetAccessRuleBindings],
)
const handleCreate = useCallback((resourceType: ResourceType) => {
const handleCreate = useCallback((resourceType: AccessPolicyResourceType) => {
setPermissionSetModalState({ mode: 'create', resourceType })
}, [])
const handleEdit = useCallback(
(resourceType: ResourceType, rule: AccessRule) => {
(resourceType: AccessPolicyResourceType, rule: AccessPolicyWithBindings) => {
const { policy } = rule
setPermissionSetModalState({
mode: 'edit',
resourceType,
initialValues: {
name: rule.name,
description: rule.description,
permissions: rule.permissions,
name: policy.name,
description: policy.description,
permissionKeys: policy.permission_keys,
},
})
},
[],
)
const { mutateAsync: createAccessRule } = useCreateAccessRule(permissionSetModalState?.resourceType)
const handlePermissionSetSubmit = useCallback(
(_values: PermissionSetFormValues) => {
// TODO: wire up to API when backend is ready.
(values: PermissionSetFormValues) => {
const { name, description, permissionKeys } = values
createAccessRule({
name,
description,
permission_keys: permissionKeys,
}, {
onSuccess: () => {
toast.success('Access rule created successfully')
closePermissionSetModal()
},
})
},
[],
[closePermissionSetModal, createAccessRule],
)
const noop = useCallback(() => {
// TODO: wire up to API when backend is ready.
}, [])
const createApp = useCallback(() => handleCreate('app'), [handleCreate])
const createKb = useCallback(() => handleCreate('knowledge_base'), [handleCreate])
const createKb = useCallback(() => handleCreate('dataset'), [handleCreate])
const editApp = useCallback(
(rule: AccessRule) => handleEdit('app', rule),
(rule: AccessPolicyWithBindings) => handleEdit('app', rule),
[handleEdit],
)
const editKb = useCallback(
(rule: AccessRule) => handleEdit('knowledge_base', rule),
(rule: AccessPolicyWithBindings) => handleEdit('dataset', rule),
[handleEdit],
)
return (
<>
<div className="flex flex-col gap-6">
<AccessRuleSection
title="App Access Rules"
rules={APP_ACCESS_RULES}
createButtonLabel="Create App permission set"
<AppAccessRuleSection
onCreate={createApp}
onEditRule={editApp}
onCopyRule={noop}
onDeleteRule={noop}
onAddRole={handleAddRole}
onRemoveRole={noop}
/>
<AccessRuleSection
title="Knowledge Base Access Rules"
rules={KNOWLEDGE_BASE_ACCESS_RULES}
createButtonLabel="Create KB permission set"
<DatasetAccessRuleSection
onCreate={createKb}
onEditRule={editKb}
onCopyRule={noop}
onDeleteRule={noop}
onAddRole={handleAddRole}
onRemoveRole={noop}
/>
</div>
{addingRule && (
<AddRuleTargetsModal
open
ruleName={addingRule.name}
initialRoleIds={addingRule.assignedRoles.map(role => role.id)}
ruleName={addingRule.policy.name}
initialRoleIds={addingRule.role_ids}
initialMemberIds={[]}
onClose={closeAddModal}
onSubmit={handleAddSubmit}

View File

@ -0,0 +1,22 @@
import type { AccessPolicyResourceType } from '@/models/access-control'
import { useAppPermissionCatalog, useDatasetPermissionCatalog } from '@/service/access-control/use-permission-catalog'
export const usePermissionsGroups = (resourceType: AccessPolicyResourceType) => {
const { data: appPermissionCatalog } = useAppPermissionCatalog(resourceType === 'app')
const { data: datasetPermissionCatalog } = useDatasetPermissionCatalog(resourceType === 'dataset')
const permissionCatalog = resourceType === 'app' ? appPermissionCatalog : datasetPermissionCatalog
const groups = permissionCatalog?.groups || []
const allPermissions = groups.flatMap(g => g.permissions) || []
const permissionMap = Object.fromEntries(
allPermissions.map(p => [p.key, p]),
)
return {
groups,
permissionMap,
}
}

View File

@ -1,6 +1,6 @@
'use client'
import type { ResourceType } from './permissions-data'
import type { AccessPolicyResourceType } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
@ -10,40 +10,40 @@ import {
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { useMemo, useState } from 'react'
import { useState } from 'react'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { usePermissionsGroups } from './hooks'
import PermissionPicker from './permission-picker'
import { getPermissionMap } from './permissions-data'
export type PermissionSetModalMode = 'create' | 'edit'
export type PermissionSetFormValues = {
name: string
description: string
permissions: string[]
permissionKeys: string[]
}
export type PermissionSetModalProps = {
open: boolean
mode: PermissionSetModalMode
resourceType: ResourceType
resourceType: AccessPolicyResourceType
initialValues?: Partial<PermissionSetFormValues>
onClose: () => void
onSubmit: (values: PermissionSetFormValues) => void
}
const RESOURCE_LABEL: Record<ResourceType, string> = {
const RESOURCE_LABEL: Record<AccessPolicyResourceType, string> = {
app: 'App',
knowledge_base: 'Knowledge Base',
dataset: 'Knowledge Base',
}
const buildTitle = (mode: PermissionSetModalMode, resource: ResourceType): string => {
const buildTitle = (mode: PermissionSetModalMode, resource: AccessPolicyResourceType): string => {
const verb = mode === 'create' ? 'Create' : 'Edit'
return `${verb} ${RESOURCE_LABEL[resource]} permission set`
}
const buildDescription = (mode: PermissionSetModalMode, resource: ResourceType): string => {
const buildDescription = (mode: PermissionSetModalMode, resource: AccessPolicyResourceType): string => {
if (mode === 'edit')
return 'Modify the name, description, and permissions granted for this permission set.'
if (resource === 'app')
@ -62,9 +62,9 @@ const PermissionSetModalBody = ({
}: PermissionSetModalBodyProps) => {
const [name, setName] = useState(initialValues?.name ?? '')
const [description, setDescription] = useState(initialValues?.description ?? '')
const [permissions, setPermissions] = useState<string[]>(initialValues?.permissions ?? [])
const [permissionKeys, setPermissionKeys] = useState<string[]>(initialValues?.permissionKeys ?? [])
const permissionMap = useMemo(() => getPermissionMap(resourceType), [resourceType])
const { permissionMap } = usePermissionsGroups(resourceType)
const trimmedName = name.trim()
const canSubmit = trimmedName.length > 0
@ -75,13 +75,13 @@ const PermissionSetModalBody = ({
onSubmit({
name: trimmedName,
description: description.trim(),
permissions,
permissionKeys,
})
onClose()
}
const handleRemovePermission = (id: string) => {
setPermissions(prev => prev.filter(p => p !== id))
const handleRemovePermission = (key: string) => {
setPermissionKeys(prev => prev.filter(p => p !== key))
}
return (
@ -132,15 +132,15 @@ const PermissionSetModalBody = ({
<div className="flex flex-col gap-2">
<div className="system-sm-medium text-text-secondary">Permissions</div>
{permissions.length > 0 && (
{permissionKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{permissions.map((id) => {
const p = permissionMap[id]
{permissionKeys.map((key) => {
const p = permissionMap[key]
if (!p)
return null
return (
<span
key={id}
key={key}
className={cn(
'inline-flex items-center gap-1 rounded-md bg-util-colors-indigo-indigo-50 px-1.5 py-0.5 system-xs-medium text-text-accent',
'border-[0.5px] border-components-panel-border',
@ -151,7 +151,7 @@ const PermissionSetModalBody = ({
type="button"
className="flex h-3.5 w-3.5 items-center justify-center rounded hover:bg-state-base-hover"
aria-label={`Remove ${p.name}`}
onClick={() => handleRemovePermission(id)}
onClick={() => handleRemovePermission(key)}
>
<span aria-hidden className="i-ri-close-line h-3 w-3" />
</button>
@ -162,8 +162,8 @@ const PermissionSetModalBody = ({
)}
<PermissionPicker
resourceType={resourceType}
value={permissions}
onChange={setPermissions}
value={permissionKeys}
onChange={setPermissionKeys}
/>
</div>
</div>

View File

@ -1,21 +1,20 @@
'use client'
import type { PermissionGroup, ResourceType } from './permissions-data'
import type { AccessPolicyResourceType, PermissionGroup } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useEffect, useMemo, useRef, useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import {
filterPermissionNodes,
PERMISSION_NODES_BY_RESOURCE,
} from './permissions-data'
import { usePermissionsGroups } from './hooks'
type PermissionPickerProps = {
resourceType: ResourceType
resourceType: AccessPolicyResourceType
value: string[]
onChange: (next: string[]) => void
className?: string
@ -42,12 +41,19 @@ const PermissionPicker = ({
return () => clearTimeout(timer)
}, [open])
const nodes = PERMISSION_NODES_BY_RESOURCE[resourceType]
const { groups } = usePermissionsGroups(resourceType)
const filtered = useMemo(
() => filterPermissionNodes(nodes, search),
[nodes, search],
)
const filteredGroups = useMemo<PermissionGroup[]>(() => {
const q = search.trim().toLowerCase()
if (!q)
return groups
return groups
.map(group => ({
...group,
permissions: group.permissions.filter(i => i.name.toLowerCase().includes(q)),
}))
.filter(group => group.permissions.length > 0)
}, [search, groups])
const selectedSet = useMemo(() => new Set(value), [value])
@ -59,19 +65,19 @@ const PermissionPicker = ({
}
const getGroupState = (group: PermissionGroup) => {
const checkedCount = group.items.reduce(
(acc, i) => acc + (selectedSet.has(i.id) ? 1 : 0),
const checkedCount = group.permissions.reduce(
(acc, i) => acc + (selectedSet.has(i.key) ? 1 : 0),
0,
)
return {
allChecked: checkedCount > 0 && checkedCount === group.items.length,
indeterminate: checkedCount > 0 && checkedCount < group.items.length,
allChecked: checkedCount > 0 && checkedCount === group.permissions.length,
indeterminate: checkedCount > 0 && checkedCount < group.permissions.length,
}
}
const toggleGroup = (group: PermissionGroup) => {
const { allChecked, indeterminate } = getGroupState(group)
const ids = group.items.map(i => i.id)
const ids = group.permissions.map(i => i.key)
if (allChecked || indeterminate) {
const idSet = new Set(ids)
onChange(value.filter(v => !idSet.has(v)))
@ -122,71 +128,46 @@ const PermissionPicker = ({
sideOffset={4}
popupClassName="max-h-80 w-[var(--anchor-width)] py-1"
>
{filtered.length === 0 && (
{filteredGroups.length === 0 && (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
No permissions found
</div>
)}
{filtered.map((node) => {
if (node.kind === 'leaf') {
const checked = selectedSet.has(node.leaf.id)
return (
<button
key={node.leaf.id}
type="button"
role="menuitemcheckbox"
aria-checked={checked}
onClick={() => togglePermission(node.leaf.id)}
className="mx-1 flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-lg px-2 text-left outline-hidden hover:bg-state-base-hover"
>
<Checkbox checked={checked} className="pointer-events-none" />
<span className="system-sm-regular text-text-secondary">
{node.leaf.name}
</span>
</button>
)
}
const { allChecked, indeterminate } = getGroupState(node.group)
{filteredGroups.map((group) => {
const { allChecked, indeterminate } = getGroupState(group)
return (
<div key={node.group.id} className="flex flex-col">
<DropdownMenuGroup key={group.group_key}>
<button
type="button"
role="menuitemcheckbox"
aria-checked={allChecked ? true : indeterminate ? 'mixed' : false}
onClick={() => toggleGroup(node.group)}
className="mx-1 flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-lg px-2 text-left outline-hidden hover:bg-state-base-hover"
onClick={() => toggleGroup(group)}
>
<Checkbox
checked={allChecked}
indeterminate={indeterminate}
className="pointer-events-none"
/>
<span className="system-sm-regular text-text-secondary">
{node.group.label}
<span className="system-xs-medium-uppercase tracking-wide text-text-tertiary">
{group.group_name}
</span>
</button>
{node.group.items.map((item) => {
const checked = selectedSet.has(item.id)
{group.permissions.map((item) => {
const checked = selectedSet.has(item.key)
return (
<button
key={item.id}
type="button"
role="menuitemcheckbox"
aria-checked={checked}
onClick={() => togglePermission(item.id)}
className={cn(
'mx-1 flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-lg pr-2 pl-7 text-left outline-hidden hover:bg-state-base-hover',
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
)}
<DropdownMenuCheckboxItem
key={item.key}
checked={checked}
onCheckedChange={() => togglePermission(item.key)}
className="gap-2 pl-6"
>
<Checkbox checked={checked} className="pointer-events-none" />
<span className="system-sm-regular text-text-secondary">
{item.name}
</span>
</button>
</DropdownMenuCheckboxItem>
)
})}
</div>
</DropdownMenuGroup>
)
})}
</DropdownMenuContent>

View File

@ -1,103 +0,0 @@
export type PermissionLeaf = {
id: string
name: string
}
export type PermissionGroup = {
id: string
label: string
items: PermissionLeaf[]
}
export type PermissionNode
= | { kind: 'leaf', leaf: PermissionLeaf }
| { kind: 'group', group: PermissionGroup }
export type ResourceType = 'app' | 'knowledge_base'
const APP_PERMISSION_NODES: PermissionNode[] = [
{ kind: 'leaf', leaf: { id: 'app.editing_and_layout', name: 'Editing and layout app' } },
{ kind: 'leaf', leaf: { id: 'app.test_and_debug', name: 'Test and debug app' } },
{ kind: 'leaf', leaf: { id: 'app.delete', name: 'Delete app' } },
{ kind: 'leaf', leaf: { id: 'app.import_export_dsl', name: 'Import and Export DSL' } },
{ kind: 'leaf', leaf: { id: 'app.release_version_management', name: 'Application Release and Version Management' } },
{ kind: 'leaf', leaf: { id: 'app.annotation_management', name: 'Annotation Management' } },
{
kind: 'group',
group: {
id: 'app.api_management',
label: 'API Management',
items: [
{ id: 'app.api_management.toggle', name: 'Enable/Disable API Access' },
{ id: 'app.api_management.create_key', name: 'Create App API Key' },
{ id: 'app.api_management.delete_key', name: 'Delete App API Key' },
],
},
},
]
const KNOWLEDGE_BASE_PERMISSION_NODES: PermissionNode[] = [
{ kind: 'leaf', leaf: { id: 'kb.view', name: 'View Knowledge Base' } },
{ kind: 'leaf', leaf: { id: 'kb.edit_configuration', name: 'Edit Knowledge Base Configuration' } },
{
kind: 'group',
group: {
id: 'kb.manage_documents',
label: 'Managing Knowledge Base Documents',
items: [
{ id: 'kb.manage_documents.add', name: 'Add Document' },
{ id: 'kb.manage_documents.delete', name: 'Delete Document' },
{ id: 'kb.manage_documents.download', name: 'Download Document' },
],
},
},
{ kind: 'leaf', leaf: { id: 'kb.import_export_pipeline', name: 'Import Pipeline from DSL / Export Knowledge Pipeline DSL' } },
{ kind: 'leaf', leaf: { id: 'kb.pipeline_publishing_versioning', name: 'Knowledge Base Pipeline Publishing and Version Management' } },
{ kind: 'leaf', leaf: { id: 'kb.delete', name: 'Delete Knowledge Base' } },
]
export const PERMISSION_NODES_BY_RESOURCE: Record<ResourceType, PermissionNode[]> = {
app: APP_PERMISSION_NODES,
knowledge_base: KNOWLEDGE_BASE_PERMISSION_NODES,
}
export const flattenPermissionNodes = (nodes: PermissionNode[]): PermissionLeaf[] => {
const out: PermissionLeaf[] = []
for (const node of nodes) {
if (node.kind === 'leaf')
out.push(node.leaf)
else
out.push(...node.group.items)
}
return out
}
export const getPermissionMap = (resourceType: ResourceType): Record<string, PermissionLeaf> => {
const flat = flattenPermissionNodes(PERMISSION_NODES_BY_RESOURCE[resourceType])
return Object.fromEntries(flat.map(p => [p.id, p]))
}
export const filterPermissionNodes = (
nodes: PermissionNode[],
keyword: string,
): PermissionNode[] => {
const q = keyword.trim().toLowerCase()
if (!q)
return nodes
const out: PermissionNode[] = []
for (const node of nodes) {
if (node.kind === 'leaf') {
if (node.leaf.name.toLowerCase().includes(q))
out.push(node)
}
else {
const matchedItems = node.group.items.filter(i => i.name.toLowerCase().includes(q))
const groupMatch = node.group.label.toLowerCase().includes(q)
if (groupMatch)
out.push(node)
else if (matchedItems.length > 0)
out.push({ kind: 'group', group: { ...node.group, items: matchedItems } })
}
}
return out
}

View File

@ -4,12 +4,18 @@ import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
export type RoleTagProps = {
id: string
label: string
onRemove?: () => void
onRemove?: (id: string) => void
className?: string
}
const RoleTag = ({ label, onRemove, className }: RoleTagProps) => {
const RoleTag = ({
id,
label,
onRemove,
className,
}: RoleTagProps) => {
return (
<span
className={cn(
@ -25,7 +31,7 @@ const RoleTag = ({ label, onRemove, className }: RoleTagProps) => {
aria-label={`Remove ${label}`}
onClick={(e) => {
e.stopPropagation()
onRemove()
onRemove(id)
}}
className="flex h-4 w-4 items-center justify-center rounded text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>

View File

@ -7,7 +7,6 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useEffect, useMemo, useRef, useState } from 'react'
@ -20,7 +19,11 @@ type PermissionPickerProps = {
className?: string
}
const PermissionPicker = ({ value, onChange, className }: PermissionPickerProps) => {
const PermissionPicker = ({
value,
onChange,
className,
}: PermissionPickerProps) => {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
@ -129,11 +132,10 @@ const PermissionPicker = ({ value, onChange, className }: PermissionPickerProps)
No permissions found
</div>
)}
{filteredGroups.map((group, groupIndex) => {
{filteredGroups.map((group) => {
const { allChecked, indeterminate } = getGroupState(group)
return (
<DropdownMenuGroup key={group.group_key}>
{groupIndex > 0 && <DropdownMenuSeparator />}
<button
type="button"
className="mx-1 flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-lg px-2 text-left outline-hidden hover:bg-state-base-hover"

View File

@ -115,17 +115,7 @@ export type AccessPolicy = {
updated_at: string
}
export type GetAccessPoliciesRequest = {
resource_type?: AccessPolicyResourceType
} & PaginationParameters
export type GetAccessPoliciesResponse = {
data: AccessPolicy[]
pagination: Pagination
}
export type CreateAccessPolicyRequest = {
resource_type: AccessPolicyResourceType
name: string
description?: string
permission_keys?: PermissionKey[]

View File

@ -13,16 +13,18 @@ export const useWorkspacePermissionCatalog = () => {
})
}
export const useAppPermissionCatalog = () => {
export const useAppPermissionCatalog = (enabled?: boolean) => {
return useQuery({
queryKey: [NAME_SPACE, 'app'],
queryFn: () => get<PermissionGroups>('/workspaces/current/rbac/role-permissions/catalog/app'),
enabled: enabled ?? true,
})
}
export const useDatasetPermissionCatalog = () => {
export const useDatasetPermissionCatalog = (enabled?: boolean) => {
return useQuery({
queryKey: [NAME_SPACE, 'dataset'],
queryFn: () => get<PermissionGroups>('/workspaces/current/rbac/role-permissions/catalog/dataset'),
enabled: enabled ?? true,
})
}

View File

@ -0,0 +1,141 @@
import type {
AccessPolicy,
AccessPolicyResourceType,
Bindings,
CreateAccessPolicyRequest,
GetAppAccessPoliciesResponse,
GetDatasetAccessPoliciesResponse,
PaginationParameters,
UpdateAccessPolicyRequest,
} from '@/models/access-control'
import type { CommonResponse } from '@/models/common'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { del, get, post, put } from '../base'
const NAME_SPACE = 'workspace-access-rules'
export const useWorkspaceAppAccessRules = (params?: PaginationParameters) => {
return useQuery({
queryKey: [NAME_SPACE, 'app', params],
queryFn: () => get<GetAppAccessPoliciesResponse>('/workspaces/current/rbac/workspace/apps/access-policy', { params }),
})
}
export const useWorkspaceDatasetAccessRules = (params?: PaginationParameters) => {
return useQuery({
queryKey: [NAME_SPACE, 'dataset', params],
queryFn: () => get<GetDatasetAccessPoliciesResponse>('/workspaces/current/rbac/workspace/datasets/access-policy', { params }),
})
}
export const useCreateAccessRule = (resourceType?: AccessPolicyResourceType) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: [NAME_SPACE, 'create', resourceType],
mutationFn: (data: CreateAccessPolicyRequest) => {
const { name, description, permission_keys } = data
return post<AccessPolicy>('/workspaces/current/rbac/access-policies', {
body: {
resource_type: resourceType,
name,
description,
permission_keys,
},
})
},
onSuccess: () => {
if (resourceType) {
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, resourceType] })
}
},
})
}
export const useUpdateAccessRule = (resourceType: AccessPolicyResourceType) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: [NAME_SPACE, 'update', resourceType],
mutationFn: (data: UpdateAccessPolicyRequest) => {
const { id, name, description, permission_keys } = data
return put<AccessPolicy>(`/workspaces/current/rbac/access-policies/${id}`, {
body: {
id,
name,
description,
permission_keys,
},
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, resourceType] })
},
})
}
export const useCopyAccessRule = (resourceType: AccessPolicyResourceType) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: [NAME_SPACE, 'copy', resourceType],
mutationFn: (id: string) => {
return post<AccessPolicy>(`/workspaces/current/rbac/access-policies/${id}/copy`, {})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, resourceType] })
},
})
}
export const useDeleteAccessRule = (resourceType: AccessPolicyResourceType) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: [NAME_SPACE, 'delete', resourceType],
mutationFn: (id: string) => {
return del<CommonResponse>(`/workspaces/current/rbac/access-policies/${id}`, {})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, resourceType] })
},
})
}
export const useUpdateAppAccessRuleBindings = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: [NAME_SPACE, 'update-app-bindings'],
mutationFn: (data: Bindings & { id: string }) => {
const { id, ...rest } = data
return put(`/workspaces/current/rbac/workspace/apps/access-policies/${id}/bindings`, {
body: {
...rest,
},
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'app'] })
},
})
}
export const useUpdateDatasetAccessRuleBindings = () => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: [NAME_SPACE, 'update-dataset-bindings'],
mutationFn: (data: Bindings & { id: string }) => {
const { id, ...rest } = data
return put(`/workspaces/current/rbac/workspace/datasets/access-policies/${id}/bindings`, {
body: {
...rest,
},
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'dataset'] })
},
})
}

View File

@ -1,8 +1,10 @@
import type {
CreateRoleRequest,
PaginationParameters,
Role,
RoleListResponse,
} from '@/models/access-control'
import type { CommonResponse } from '@/models/common'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { del, get, post, put } from '../base'
@ -21,7 +23,7 @@ export const useCreateWorkspaceRole = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'create-workspace-role'],
mutationFn: (data: CreateRoleRequest) =>
post<RoleListResponse>('/workspaces/current/rbac/roles', {
post<Role>('/workspaces/current/rbac/roles', {
body: { ...data },
}),
onSuccess: () => {
@ -36,7 +38,7 @@ export const useUpdateWorkspaceRole = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'update-workspace-role'],
mutationFn: (data: CreateRoleRequest & { id: string }) =>
put<RoleListResponse>(`/workspaces/current/rbac/roles/${data.id}`, {
put<Role>(`/workspaces/current/rbac/roles/${data.id}`, {
body: { ...data },
}),
onSuccess: () => {
@ -51,7 +53,7 @@ export const useDeleteWorkspaceRole = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'delete-workspace-role'],
mutationFn: (id: string) =>
del(`/workspaces/current/rbac/roles/${id}`),
del<CommonResponse>(`/workspaces/current/rbac/roles/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [NAME_SPACE, 'workspace-role-list'] })
},