diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index f53098660b..3d552c84de 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -78,7 +78,7 @@ export default function AccountSetting({ key: ACCOUNT_SETTING_TAB.PERMISSIONS, name: t('settings.permissions', { ns: 'common' }), icon: , - activeIcon: , + activeIcon: , }, { key: ACCOUNT_SETTING_TAB.ACCESS_RULES, diff --git a/web/app/components/header/account-setting/permissions-page/index.tsx b/web/app/components/header/account-setting/permissions-page/index.tsx index 54272a03ed..7cf90f7541 100644 --- a/web/app/components/header/account-setting/permissions-page/index.tsx +++ b/web/app/components/header/account-setting/permissions-page/index.tsx @@ -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(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 ( <> @@ -17,12 +158,27 @@ const PermissionsPage = () => { + Add Role + + {modalState && ( + + )} > ) } diff --git a/web/app/components/header/account-setting/permissions-page/role-list/index.tsx b/web/app/components/header/account-setting/permissions-page/role-list/index.tsx new file mode 100644 index 0000000000..48fccb64b9 --- /dev/null +++ b/web/app/components/header/account-setting/permissions-page/role-list/index.tsx @@ -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 ( + + {groups.map((group, groupIndex) => ( + 0 && 'mt-6')} + > + + {group.title} + + + {group.items.map((row, rowIndex) => ( + 0 && 'border-t border-divider-subtle', + )} + name={row.name} + description={row.description} + roleType={group.type} + role={row} + onView={onView} + onEdit={onEdit} + onDelete={onDelete} + /> + ))} + + + ))} + + ) +} + +export default RoleList diff --git a/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx b/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx new file mode 100644 index 0000000000..2a4c4cc6c7 --- /dev/null +++ b/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx @@ -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 ( + + }> + + + + { + roleType === 'system' && ( + + View + + ) + } + { + roleType === 'custom' && ( + <> + + Edit + + + Duplicate + + + + Delete + + > + ) + } + + + ) +} + +export default RowMenu diff --git a/web/app/components/header/account-setting/permissions-page/role-list/row.tsx b/web/app/components/header/account-setting/permissions-page/role-list/row.tsx new file mode 100644 index 0000000000..d4f67cd725 --- /dev/null +++ b/web/app/components/header/account-setting/permissions-page/role-list/row.tsx @@ -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 ( + + + + {name} + + + {description} + + + + + ) +} + +export default memo(Row) diff --git a/web/app/components/header/account-setting/permissions-page/role-modal/index.tsx b/web/app/components/header/account-setting/permissions-page/role-modal/index.tsx new file mode 100644 index 0000000000..6698318785 --- /dev/null +++ b/web/app/components/header/account-setting/permissions-page/role-modal/index.tsx @@ -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 = { + 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(role?.permissions ?? []) + + const readonly = mode === 'view' + const { title, description } = TITLES[mode] + + const handleSubmit = () => { + onSubmit?.({ name: name.trim(), description: desc.trim(), permissions }) + onClose() + } + + return ( + { + if (!nextOpen) + onClose() + }} + > + + + + + + {title} + + + {description} + + + + + + + + Role name + + setName(e.target.value)} + placeholder="e.g. Marketing Lead" + disabled={readonly} + /> + + + + Description + + setDesc(e.target.value)} + placeholder="Describe what this role is responsible for" + disabled={readonly} + className="min-h-24 resize-none" + /> + + + + + + Learn more about permissions + + + + + {readonly ? 'Close' : 'Cancel'} + + {!readonly && ( + + Confirm + + )} + + + + + ) +} + +export default RoleModal diff --git a/web/app/components/header/account-setting/permissions-page/role-modal/permission-field.tsx b/web/app/components/header/account-setting/permissions-page/role-modal/permission-field.tsx new file mode 100644 index 0000000000..560431e9a6 --- /dev/null +++ b/web/app/components/header/account-setting/permissions-page/role-modal/permission-field.tsx @@ -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 ( + + Permissions + {value.length > 0 && ( + + {value.map((id) => { + const p = PERMISSION_MAP[id] + if (!p) + return null + return ( + + {p.name} + {!readonly && ( + handleRemove(id)} + > + + + )} + + ) + })} + + )} + {!readonly && ( + + )} + + ) +} + +export default PermissionField diff --git a/web/app/components/header/account-setting/permissions-page/role-modal/permission-picker.tsx b/web/app/components/header/account-setting/permissions-page/role-modal/permission-picker.tsx new file mode 100644 index 0000000000..9267bb7756 --- /dev/null +++ b/web/app/components/header/account-setting/permissions-page/role-modal/permission-picker.tsx @@ -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(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(() => { + 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 ( + + + + + setSearch(e.target.value)} + onFocus={() => setOpen(true)} + onMouseDown={e => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Escape') + setOpen(false) + }} + /> + + + + + {filteredGroups.length === 0 && ( + + No permissions found + + )} + {filteredGroups.map((group, groupIndex) => { + const { allChecked, indeterminate } = getGroupState(group) + return ( + + {groupIndex > 0 && } + toggleGroup(group)} + > + + + {group.label} + + + {group.items.map((item) => { + const checked = selectedSet.has(item.id) + return ( + togglePermission(item.id)} + className="gap-2 pl-6" + > + + + {item.name} + + + ) + })} + + ) + })} + + + ) +} + +export default PermissionPicker diff --git a/web/app/components/header/account-setting/permissions-page/role-modal/permissions-data.ts b/web/app/components/header/account-setting/permissions-page/role-modal/permissions-data.ts new file mode 100644 index 0000000000..da37c81e10 --- /dev/null +++ b/web/app/components/header/account-setting/permissions-page/role-modal/permissions-data.ts @@ -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 = Object.fromEntries( + ALL_PERMISSIONS.map(p => [p.id, p]), +)
+ {description} +