mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
feat: add permission management modal and permission picker for access rules
This commit is contained in:
parent
339e4c8a1f
commit
5f4b086e39
@ -15,6 +15,7 @@ export type AccessRule = {
|
||||
name: string
|
||||
description: string
|
||||
assignedRoles: AssignedRole[]
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export type AccessRuleRowProps = {
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
'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 { useCallback, useState } from 'react'
|
||||
import AccessRuleSection from './access-rule-section'
|
||||
import AddRuleTargetsModal from './add-rule-targets-modal'
|
||||
import PermissionSetModal from './permission-set-modal'
|
||||
|
||||
// todo: replace with API data when backend is ready
|
||||
const APP_ACCESS_RULES: AccessRule[] = [
|
||||
{
|
||||
id: 'app-full-access',
|
||||
@ -17,6 +19,17 @@ const APP_ACCESS_RULES: AccessRule[] = [
|
||||
{ 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',
|
||||
@ -26,6 +39,11 @@ const APP_ACCESS_RULES: AccessRule[] = [
|
||||
{ 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',
|
||||
@ -36,6 +54,9 @@ const APP_ACCESS_RULES: AccessRule[] = [
|
||||
{ id: 'ops-staff', name: 'Ops Staff' },
|
||||
{ id: 'member', name: 'Member' },
|
||||
],
|
||||
permissions: [
|
||||
'app.test_and_debug',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-can-preview',
|
||||
@ -44,10 +65,10 @@ const APP_ACCESS_RULES: AccessRule[] = [
|
||||
assignedRoles: [
|
||||
{ id: 'partner', name: 'Partner' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
]
|
||||
|
||||
// todo: replace with API data when backend is ready
|
||||
const KNOWLEDGE_BASE_ACCESS_RULES: AccessRule[] = [
|
||||
{
|
||||
id: 'kb-full-access',
|
||||
@ -59,6 +80,16 @@ const KNOWLEDGE_BASE_ACCESS_RULES: AccessRule[] = [
|
||||
{ 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',
|
||||
@ -69,6 +100,12 @@ const KNOWLEDGE_BASE_ACCESS_RULES: AccessRule[] = [
|
||||
{ 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',
|
||||
@ -77,6 +114,7 @@ const KNOWLEDGE_BASE_ACCESS_RULES: AccessRule[] = [
|
||||
assignedRoles: [
|
||||
{ id: 'member', name: 'Member' },
|
||||
],
|
||||
permissions: ['kb.view'],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-preview',
|
||||
@ -85,6 +123,7 @@ const KNOWLEDGE_BASE_ACCESS_RULES: AccessRule[] = [
|
||||
assignedRoles: [
|
||||
{ id: 'partner', name: 'Partner' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-test',
|
||||
@ -93,20 +132,33 @@ const KNOWLEDGE_BASE_ACCESS_RULES: AccessRule[] = [
|
||||
assignedRoles: [
|
||||
{ id: 'tester', name: 'Tester' },
|
||||
],
|
||||
permissions: ['kb.view'],
|
||||
},
|
||||
]
|
||||
|
||||
type PermissionSetModalState = {
|
||||
mode: PermissionSetModalMode
|
||||
resourceType: ResourceType
|
||||
initialValues?: PermissionSetFormValues
|
||||
}
|
||||
|
||||
const AccessRulesPage = () => {
|
||||
const [addingRule, setAddingRule] = useState<AccessRule | null>(null)
|
||||
|
||||
const handleAddRole = useCallback((rule: AccessRule) => {
|
||||
setAddingRule(rule)
|
||||
}, [])
|
||||
const [permissionSetModalState, setPermissionSetModalState]
|
||||
= useState<PermissionSetModalState | null>(null)
|
||||
|
||||
const closeAddModal = useCallback(() => {
|
||||
setAddingRule(null)
|
||||
}, [])
|
||||
|
||||
const closePermissionSetModal = useCallback(() => {
|
||||
setPermissionSetModalState(null)
|
||||
}, [])
|
||||
|
||||
const handleAddRole = useCallback((rule: AccessRule) => {
|
||||
setAddingRule(rule)
|
||||
}, [])
|
||||
|
||||
const handleAddSubmit = useCallback(
|
||||
(_selection: { roleIds: string[], memberIds: string[] }) => {
|
||||
// TODO: wire up to API when backend is ready.
|
||||
@ -114,10 +166,47 @@ const AccessRulesPage = () => {
|
||||
[],
|
||||
)
|
||||
|
||||
const handleCreate = useCallback((resourceType: ResourceType) => {
|
||||
setPermissionSetModalState({ mode: 'create', resourceType })
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(resourceType: ResourceType, rule: AccessRule) => {
|
||||
setPermissionSetModalState({
|
||||
mode: 'edit',
|
||||
resourceType,
|
||||
initialValues: {
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
permissions: rule.permissions,
|
||||
},
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handlePermissionSetSubmit = useCallback(
|
||||
(_values: PermissionSetFormValues) => {
|
||||
// TODO: wire up to API when backend is ready.
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
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 editApp = useCallback(
|
||||
(rule: AccessRule) => handleEdit('app', rule),
|
||||
[handleEdit],
|
||||
)
|
||||
const editKb = useCallback(
|
||||
(rule: AccessRule) => handleEdit('knowledge_base', rule),
|
||||
[handleEdit],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
@ -125,8 +214,8 @@ const AccessRulesPage = () => {
|
||||
title="App Access Rules"
|
||||
rules={APP_ACCESS_RULES}
|
||||
createButtonLabel="Create App permission set"
|
||||
onCreate={noop}
|
||||
onEditRule={noop}
|
||||
onCreate={createApp}
|
||||
onEditRule={editApp}
|
||||
onCopyRule={noop}
|
||||
onDeleteRule={noop}
|
||||
onAddRole={handleAddRole}
|
||||
@ -136,8 +225,8 @@ const AccessRulesPage = () => {
|
||||
title="Knowledge Base Access Rules"
|
||||
rules={KNOWLEDGE_BASE_ACCESS_RULES}
|
||||
createButtonLabel="Create KB permission set"
|
||||
onCreate={noop}
|
||||
onEditRule={noop}
|
||||
onCreate={createKb}
|
||||
onEditRule={editKb}
|
||||
onCopyRule={noop}
|
||||
onDeleteRule={noop}
|
||||
onAddRole={handleAddRole}
|
||||
@ -154,6 +243,16 @@ const AccessRulesPage = () => {
|
||||
onSubmit={handleAddSubmit}
|
||||
/>
|
||||
)}
|
||||
{permissionSetModalState && (
|
||||
<PermissionSetModal
|
||||
open
|
||||
mode={permissionSetModalState.mode}
|
||||
resourceType={permissionSetModalState.resourceType}
|
||||
initialValues={permissionSetModalState.initialValues}
|
||||
onClose={closePermissionSetModal}
|
||||
onSubmit={handlePermissionSetSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import type { ResourceType } from './permissions-data'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import PermissionPicker from './permission-picker'
|
||||
import { getPermissionMap } from './permissions-data'
|
||||
|
||||
export type PermissionSetModalMode = 'create' | 'edit'
|
||||
|
||||
export type PermissionSetFormValues = {
|
||||
name: string
|
||||
description: string
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export type PermissionSetModalProps = {
|
||||
open: boolean
|
||||
mode: PermissionSetModalMode
|
||||
resourceType: ResourceType
|
||||
initialValues?: Partial<PermissionSetFormValues>
|
||||
onClose: () => void
|
||||
onSubmit: (values: PermissionSetFormValues) => void
|
||||
}
|
||||
|
||||
const RESOURCE_LABEL: Record<ResourceType, string> = {
|
||||
app: 'App',
|
||||
knowledge_base: 'Knowledge Base',
|
||||
}
|
||||
|
||||
const buildTitle = (mode: PermissionSetModalMode, resource: ResourceType): string => {
|
||||
const verb = mode === 'create' ? 'Create' : 'Edit'
|
||||
return `${verb} ${RESOURCE_LABEL[resource]} permission set`
|
||||
}
|
||||
|
||||
const buildDescription = (mode: PermissionSetModalMode, resource: ResourceType): string => {
|
||||
if (mode === 'edit')
|
||||
return 'Modify the name, description, and permissions granted for this permission set.'
|
||||
if (resource === 'app')
|
||||
return 'Create an app permission set that can be referenced in access rules for quick authorization.'
|
||||
return 'Create a knowledge base permission set that can be referenced in access rules for quick authorization.'
|
||||
}
|
||||
|
||||
type PermissionSetModalBodyProps = Omit<PermissionSetModalProps, 'open'>
|
||||
|
||||
const PermissionSetModalBody = ({
|
||||
mode,
|
||||
resourceType,
|
||||
initialValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: PermissionSetModalBodyProps) => {
|
||||
const [name, setName] = useState(initialValues?.name ?? '')
|
||||
const [description, setDescription] = useState(initialValues?.description ?? '')
|
||||
const [permissions, setPermissions] = useState<string[]>(initialValues?.permissions ?? [])
|
||||
|
||||
const permissionMap = useMemo(() => getPermissionMap(resourceType), [resourceType])
|
||||
|
||||
const trimmedName = name.trim()
|
||||
const canSubmit = trimmedName.length > 0
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!canSubmit)
|
||||
return
|
||||
onSubmit({
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
permissions,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleRemovePermission = (id: string) => {
|
||||
setPermissions(prev => prev.filter(p => p !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="max-h-[85vh] w-[560px] overflow-visible p-0"
|
||||
backdropProps={{ forceRender: true }}
|
||||
>
|
||||
<div className="relative px-6 pt-6 pb-4">
|
||||
<DialogCloseButton />
|
||||
<div className="pr-8">
|
||||
<DialogTitle className="system-xl-semibold text-text-primary">
|
||||
{buildTitle(mode, resourceType)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{buildDescription(mode, resourceType)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-divider-subtle" />
|
||||
|
||||
<div className="flex flex-col gap-5 px-6 py-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="permission-set-name" className="system-sm-medium text-text-secondary">
|
||||
permission set name
|
||||
<span aria-hidden className="ml-0.5 text-text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="permission-set-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Can export DSL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="permission-set-description" className="system-sm-medium text-text-secondary">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="permission-set-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Describe what this permission set grants"
|
||||
className="min-h-20 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="system-sm-medium text-text-secondary">Permissions</div>
|
||||
{permissions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{permissions.map((id) => {
|
||||
const p = permissionMap[id]
|
||||
if (!p)
|
||||
return null
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<PermissionPicker
|
||||
resourceType={resourceType}
|
||||
value={permissions}
|
||||
onChange={setPermissions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
|
||||
<a
|
||||
href="https://docs.dify.ai/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
|
||||
>
|
||||
<span>Learn more about permissions</span>
|
||||
<span aria-hidden className="i-ri-external-link-line h-3.5 w-3.5" />
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
const PermissionSetModal = ({
|
||||
open,
|
||||
mode,
|
||||
resourceType,
|
||||
initialValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: PermissionSetModalProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<PermissionSetModalBody
|
||||
mode={mode}
|
||||
resourceType={resourceType}
|
||||
initialValues={initialValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionSetModal
|
||||
@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import type { PermissionGroup, ResourceType } from './permissions-data'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
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'
|
||||
|
||||
type PermissionPickerProps = {
|
||||
resourceType: ResourceType
|
||||
value: string[]
|
||||
onChange: (next: string[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PermissionPicker = ({
|
||||
resourceType,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: PermissionPickerProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Re-focus the search input after the dropdown takes over focus, so the user
|
||||
// can keep typing to filter permissions.
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open])
|
||||
|
||||
const nodes = PERMISSION_NODES_BY_RESOURCE[resourceType]
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterPermissionNodes(nodes, search),
|
||||
[nodes, search],
|
||||
)
|
||||
|
||||
const selectedSet = useMemo(() => new Set(value), [value])
|
||||
|
||||
const togglePermission = (id: string) => {
|
||||
if (selectedSet.has(id))
|
||||
onChange(value.filter(v => v !== id))
|
||||
else
|
||||
onChange([...value, id])
|
||||
}
|
||||
|
||||
const getGroupState = (group: PermissionGroup) => {
|
||||
const checkedCount = group.items.reduce(
|
||||
(acc, i) => acc + (selectedSet.has(i.id) ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
return {
|
||||
allChecked: checkedCount > 0 && checkedCount === group.items.length,
|
||||
indeterminate: checkedCount > 0 && checkedCount < group.items.length,
|
||||
}
|
||||
}
|
||||
|
||||
const toggleGroup = (group: PermissionGroup) => {
|
||||
const { allChecked, indeterminate } = getGroupState(group)
|
||||
const ids = group.items.map(i => i.id)
|
||||
if (allChecked || indeterminate) {
|
||||
const idSet = new Set(ids)
|
||||
onChange(value.filter(v => !idSet.has(v)))
|
||||
}
|
||||
else {
|
||||
const next = new Set(value)
|
||||
ids.forEach(id => next.add(id))
|
||||
onChange(Array.from(next))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-text items-center gap-2 rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-components-input-bg-hover',
|
||||
open && 'bg-components-input-bg-active shadow-xs ring-[0.5px] ring-components-input-border-active',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="min-w-0 grow appearance-none bg-transparent system-sm-regular text-text-primary caret-primary-600 outline-hidden placeholder:text-text-tertiary"
|
||||
placeholder="Search permissions..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Escape')
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="max-h-80 w-[var(--anchor-width)] py-1"
|
||||
>
|
||||
{filtered.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)
|
||||
return (
|
||||
<div key={node.group.id} className="flex flex-col">
|
||||
<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"
|
||||
>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={indeterminate}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{node.group.label}
|
||||
</span>
|
||||
</button>
|
||||
{node.group.items.map((item) => {
|
||||
const checked = selectedSet.has(item.id)
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<Checkbox checked={checked} className="pointer-events-none" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{item.name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionPicker
|
||||
@ -0,0 +1,103 @@
|
||||
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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user