feat: implement role management UI in permissions page

This commit is contained in:
twwu 2026-04-23 16:16:29 +08:00
parent 73551495c5
commit 6a2bb145e3
9 changed files with 806 additions and 1 deletions

View File

@ -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,

View File

@ -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}
/>
)}
</>
)
}

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]),
)