mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
feat: add app and dataset access rule sections with hooks for managing access policies
This commit is contained in:
parent
212252bb78
commit
f7807c532d
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
141
web/service/access-control/use-workspace-access-rules.ts
Normal file
141
web/service/access-control/use-workspace-access-rules.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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'] })
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user