mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 12:59:18 +08:00
feat: implement role management UI in permissions page
This commit is contained in:
parent
73551495c5
commit
6a2bb145e3
@ -78,7 +78,7 @@ export default function AccountSetting({
|
||||
key: ACCOUNT_SETTING_TAB.PERMISSIONS,
|
||||
name: t('settings.permissions', { ns: 'common' }),
|
||||
icon: <span className={cn('i-ri-user-settings-line', iconClassName)} />,
|
||||
activeIcon: <span className={cn('i-ri-shield-user-fill', iconClassName)} />,
|
||||
activeIcon: <span className={cn('i-ri-user-settings-fill', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.ACCESS_RULES,
|
||||
|
||||
@ -1,6 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import type { Role, RoleListGroup } from './role-list'
|
||||
import type { RoleModalMode } from './role-modal'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useCallback, useState } from 'react'
|
||||
import RoleList from './role-list'
|
||||
import RoleModal from './role-modal'
|
||||
|
||||
const MOCK_ROLE_GROUPS: RoleListGroup[] = [
|
||||
{
|
||||
id: 'system',
|
||||
type: 'system',
|
||||
title: 'System roles',
|
||||
items: [
|
||||
{
|
||||
id: 'owner',
|
||||
name: 'Owner',
|
||||
description: 'Full access to all workspace features and settings.',
|
||||
permissions: [
|
||||
'manage_model_providers',
|
||||
'manage_members',
|
||||
'manage_roles_permissions',
|
||||
'manage_billing',
|
||||
'manage_data_sources',
|
||||
'manage_api_extensions',
|
||||
'create_apps',
|
||||
'view_all_apps',
|
||||
'delete_any_app',
|
||||
'create_knowledge_bases',
|
||||
'view_all_knowledge_bases',
|
||||
'delete_any_knowledge_base',
|
||||
'view_all_app_logs',
|
||||
'cross_app_log_access',
|
||||
'view_sensitive_fields',
|
||||
'install_plugins',
|
||||
'uninstall_plugins',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
name: 'Admin',
|
||||
description: 'Manage apps, update settings, manage members and permissions.',
|
||||
permissions: [
|
||||
'manage_members',
|
||||
'manage_roles_permissions',
|
||||
'manage_data_sources',
|
||||
'create_apps',
|
||||
'view_all_apps',
|
||||
'create_knowledge_bases',
|
||||
'view_all_knowledge_bases',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'editor',
|
||||
name: 'Editor',
|
||||
description: 'Create and edit resources (knowledge bases, apps, plugins) without workspace settings access.',
|
||||
permissions: [
|
||||
'create_apps',
|
||||
'view_all_apps',
|
||||
'create_knowledge_bases',
|
||||
'view_all_knowledge_bases',
|
||||
'install_plugins',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'member',
|
||||
name: 'Member',
|
||||
description: 'Limited permissions within the workspace.',
|
||||
permissions: ['view_all_apps', 'view_all_knowledge_bases'],
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
name: 'No Permission',
|
||||
description: 'Default role with no permissions assigned.',
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
type: 'custom',
|
||||
title: 'Custom roles',
|
||||
items: [
|
||||
{
|
||||
id: 'executive',
|
||||
name: 'Executive',
|
||||
description: 'Unrestricted access to all workspace operations.',
|
||||
permissions: [
|
||||
'manage_model_providers',
|
||||
'manage_members',
|
||||
'manage_roles_permissions',
|
||||
'manage_billing',
|
||||
'create_apps',
|
||||
'view_all_apps',
|
||||
'create_knowledge_bases',
|
||||
'view_all_knowledge_bases',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'employee',
|
||||
name: 'Employee',
|
||||
description: 'Access to payroll bot and internal project knowledge bases.',
|
||||
permissions: ['view_all_apps', 'view_all_knowledge_bases'],
|
||||
},
|
||||
{
|
||||
id: 'partner',
|
||||
name: 'Partner',
|
||||
description: 'View external-facing apps: product info, feedback forms, and visitor registration.',
|
||||
permissions: ['view_all_apps'],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
type ModalState = {
|
||||
mode: RoleModalMode
|
||||
role?: Role
|
||||
} | null
|
||||
|
||||
const PermissionsPage = () => {
|
||||
const [modalState, setModalState] = useState<ModalState>(null)
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setModalState({ mode: 'create' })
|
||||
}, [])
|
||||
|
||||
const handleView = useCallback((role: Role) => {
|
||||
setModalState({ mode: 'view', role })
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback((role: Role) => {
|
||||
setModalState({ mode: 'edit', role })
|
||||
}, [])
|
||||
|
||||
const closeModal = useCallback(() => setModalState(null), [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(_data: { name: string, description: string, permissions: string[] }) => {
|
||||
// TODO: wire up to API when backend is ready
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
@ -17,12 +158,27 @@ const PermissionsPage = () => {
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={openCreate}
|
||||
>
|
||||
+ Add Role
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<RoleList
|
||||
groups={MOCK_ROLE_GROUPS}
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
</div>
|
||||
{modalState && (
|
||||
<RoleModal
|
||||
mode={modalState?.mode ?? 'create'}
|
||||
open
|
||||
role={modalState?.role}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import Row from './row'
|
||||
|
||||
export type Role = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export type RoleType = 'system' | 'custom'
|
||||
|
||||
export type RoleListGroup = {
|
||||
id: string
|
||||
type: RoleType
|
||||
title: string
|
||||
items: Role[]
|
||||
}
|
||||
|
||||
export type RoleListProps = {
|
||||
groups: RoleListGroup[]
|
||||
className?: string
|
||||
onView?: (role: Role) => void
|
||||
onEdit?: (role: Role) => void
|
||||
onDelete?: (role: Role) => void
|
||||
}
|
||||
|
||||
const RoleList = ({
|
||||
groups,
|
||||
className,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: RoleListProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<section
|
||||
key={group.id}
|
||||
className={cn(groupIndex > 0 && 'mt-6')}
|
||||
>
|
||||
<h3 className="mb-2 px-3 system-xs-medium-uppercase tracking-wide text-text-tertiary">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-xl border-[0.5px] border-divider-subtle bg-background-section-burn">
|
||||
{group.items.map((row, rowIndex) => (
|
||||
<Row
|
||||
key={row.id}
|
||||
className={cn(
|
||||
rowIndex > 0 && 'border-t border-divider-subtle',
|
||||
)}
|
||||
name={row.name}
|
||||
description={row.description}
|
||||
roleType={group.type}
|
||||
role={row}
|
||||
onView={onView}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleList
|
||||
@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
import type { Role, RoleType } from '.'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useCallback, useState } from 'react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
|
||||
type RowMenuProps = {
|
||||
roleType: RoleType
|
||||
role: Role
|
||||
onView?: (role: Role) => void
|
||||
onEdit?: (role: Role) => void
|
||||
onDelete?: (role: Role) => void
|
||||
}
|
||||
|
||||
const RowMenu = ({
|
||||
roleType,
|
||||
role,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: RowMenuProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleView = useCallback(() => onView?.(role), [onView, role])
|
||||
|
||||
const handleEdit = useCallback(() => onEdit?.(role), [onEdit, role])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
// TODO: wire up to API when backend is ready
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(() => onDelete?.(role), [onDelete, role])
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger render={<ActionButton size="l" className={open ? 'bg-state-base-hover' : ''} aria-label="More actions" />}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[160px]">
|
||||
{
|
||||
roleType === 'system' && (
|
||||
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleView}>
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
{
|
||||
roleType === 'custom' && (
|
||||
<>
|
||||
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleEdit}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleDuplicate}>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" className="system-sm-semibold" onClick={handleDelete}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default RowMenu
|
||||
@ -0,0 +1,53 @@
|
||||
import type { Role, RoleType } from '.'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo } from 'react'
|
||||
import RowMenu from './row-menu'
|
||||
|
||||
type RowProps = {
|
||||
className?: string
|
||||
name: string
|
||||
description: string
|
||||
roleType: RoleType
|
||||
role: Role
|
||||
onView?: (role: Role) => void
|
||||
onEdit?: (role: Role) => void
|
||||
onDelete?: (role: Role) => void
|
||||
}
|
||||
|
||||
const Row = ({
|
||||
className,
|
||||
name,
|
||||
description,
|
||||
roleType,
|
||||
role,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: RowProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-4 py-3.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{name}
|
||||
</div>
|
||||
<p className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<RowMenu
|
||||
roleType={roleType}
|
||||
role={role}
|
||||
onView={onView}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Row)
|
||||
@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import type { Role } from '../role-list'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useState } from 'react'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import PermissionField from './permission-field'
|
||||
|
||||
export type RoleModalMode = 'create' | 'view' | 'edit'
|
||||
|
||||
export type RoleModalRole = Role & {
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export type RoleModalProps = {
|
||||
mode: RoleModalMode
|
||||
open: boolean
|
||||
role?: RoleModalRole
|
||||
onClose: () => void
|
||||
onSubmit?: (data: { name: string, description: string, permissions: string[] }) => void
|
||||
}
|
||||
|
||||
const TITLES: Record<RoleModalMode, { title: string, description: string }> = {
|
||||
create: {
|
||||
title: 'Create Role',
|
||||
description: 'Create a role and assign permissions',
|
||||
},
|
||||
edit: {
|
||||
title: 'Edit Role',
|
||||
description: 'Edit role details and permissions',
|
||||
},
|
||||
view: {
|
||||
title: 'View Role',
|
||||
description: 'View role details and permissions',
|
||||
},
|
||||
}
|
||||
|
||||
const RoleModal = ({
|
||||
mode,
|
||||
open,
|
||||
role,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: RoleModalProps) => {
|
||||
const [name, setName] = useState(role?.name ?? '')
|
||||
const [desc, setDesc] = useState(role?.description ?? '')
|
||||
const [permissions, setPermissions] = useState<string[]>(role?.permissions ?? [])
|
||||
|
||||
const readonly = mode === 'view'
|
||||
const { title, description } = TITLES[mode]
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit?.({ name: name.trim(), description: desc.trim(), permissions })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="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">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{description}
|
||||
</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="role-name" className="system-sm-medium text-text-secondary">
|
||||
Role name
|
||||
</label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Marketing Lead"
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="role-description" className="system-sm-medium text-text-secondary">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="role-description"
|
||||
value={desc}
|
||||
onChange={e => setDesc(e.target.value)}
|
||||
placeholder="Describe what this role is responsible for"
|
||||
disabled={readonly}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<PermissionField
|
||||
value={permissions}
|
||||
onChange={setPermissions}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</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}>
|
||||
{readonly ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
{!readonly && (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim()}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleModal
|
||||
@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import PermissionPicker from './permission-picker'
|
||||
import { PERMISSION_MAP } from './permissions-data'
|
||||
|
||||
export type PermissionFieldProps = {
|
||||
value: string[]
|
||||
onChange: (next: string[]) => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const PermissionField = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
}: PermissionFieldProps) => {
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(value.filter(p => p !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="system-sm-medium text-text-secondary">Permissions</div>
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.map((id) => {
|
||||
const p = PERMISSION_MAP[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>
|
||||
{!readonly && (
|
||||
<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={() => handleRemove(id)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!readonly && (
|
||||
<PermissionPicker value={value} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionField
|
||||
@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import type { PermissionGroup } from './permissions-data'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { PERMISSION_GROUPS } from './permissions-data'
|
||||
|
||||
type PermissionPickerProps = {
|
||||
value: string[]
|
||||
onChange: (next: string[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PermissionPicker = ({ value, onChange, className }: PermissionPickerProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Base UI Menu's FloatingFocusManager hard-codes `initialFocus: true` for top-level
|
||||
// menus, which steals focus from the trigger input on open. Re-focus the input on the
|
||||
// next tick 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 filteredGroups = useMemo<PermissionGroup[]>(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q)
|
||||
return PERMISSION_GROUPS
|
||||
return PERMISSION_GROUPS
|
||||
.map(group => ({
|
||||
...group,
|
||||
items: group.items.filter(i => i.name.toLowerCase().includes(q)),
|
||||
}))
|
||||
.filter(group => group.items.length > 0)
|
||||
}, [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)]"
|
||||
>
|
||||
{filteredGroups.length === 0 && (
|
||||
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
No permissions found
|
||||
</div>
|
||||
)}
|
||||
{filteredGroups.map((group, groupIndex) => {
|
||||
const { allChecked, indeterminate } = getGroupState(group)
|
||||
return (
|
||||
<DropdownMenuGroup key={group.id}>
|
||||
{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"
|
||||
onClick={() => toggleGroup(group)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={indeterminate}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="system-xs-medium-uppercase tracking-wide text-text-tertiary">
|
||||
{group.label}
|
||||
</span>
|
||||
</button>
|
||||
{group.items.map((item) => {
|
||||
const checked = selectedSet.has(item.id)
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.id}
|
||||
checked={checked}
|
||||
onCheckedChange={() => togglePermission(item.id)}
|
||||
className="gap-2 pl-6"
|
||||
>
|
||||
<Checkbox checked={checked} className="pointer-events-none" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{item.name}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionPicker
|
||||
@ -0,0 +1,66 @@
|
||||
export type Permission = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type PermissionGroup = {
|
||||
id: string
|
||||
label: string
|
||||
items: Permission[]
|
||||
}
|
||||
|
||||
export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
items: [
|
||||
{ id: 'manage_model_providers', name: 'Manage model providers' },
|
||||
{ id: 'manage_members', name: 'Manage members' },
|
||||
{ id: 'manage_roles_permissions', name: 'Manage roles & permissions' },
|
||||
{ id: 'manage_billing', name: 'Manage billing' },
|
||||
{ id: 'manage_data_sources', name: 'Manage data sources' },
|
||||
{ id: 'manage_api_extensions', name: 'Manage API extensions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
label: 'Apps',
|
||||
items: [
|
||||
{ id: 'create_apps', name: 'Create apps' },
|
||||
{ id: 'view_all_apps', name: 'View all apps' },
|
||||
{ id: 'delete_any_app', name: 'Delete any app' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
label: 'Knowledge',
|
||||
items: [
|
||||
{ id: 'create_knowledge_bases', name: 'Create knowledge bases' },
|
||||
{ id: 'view_all_knowledge_bases', name: 'View all knowledge bases' },
|
||||
{ id: 'delete_any_knowledge_base', name: 'Delete any knowledge base' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'logs_audit',
|
||||
label: 'Logs & Audit',
|
||||
items: [
|
||||
{ id: 'view_all_app_logs', name: 'View all app logs' },
|
||||
{ id: 'cross_app_log_access', name: 'Cross-app log access' },
|
||||
{ id: 'view_sensitive_fields', name: 'View sensitive fields' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plugins',
|
||||
label: 'Plugins',
|
||||
items: [
|
||||
{ id: 'install_plugins', name: 'Install plugins' },
|
||||
{ id: 'uninstall_plugins', name: 'Uninstall plugins' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const ALL_PERMISSIONS: Permission[] = PERMISSION_GROUPS.flatMap(g => g.items)
|
||||
|
||||
export const PERMISSION_MAP: Record<string, Permission> = Object.fromEntries(
|
||||
ALL_PERMISSIONS.map(p => [p.id, p]),
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user