From 2dc080c845afcdb6bd195e30a81ba47bc5977160 Mon Sep 17 00:00:00 2001 From: twwu Date: Thu, 23 Apr 2026 18:07:12 +0800 Subject: [PATCH] feat: add member management features with role assignment and member actions --- .../members-page/__tests__/index.spec.tsx | 16 +- .../members-page/assign-roles-modal/index.tsx | 218 ++++++++++++++++++ .../account-setting/members-page/index.tsx | 37 ++- .../members-page/member-menu.tsx | 134 +++++++++++ .../members-page/role-badges.tsx | 53 +++++ web/i18n/en-US/common.json | 7 + web/i18n/zh-Hans/common.json | 7 + 7 files changed, 462 insertions(+), 10 deletions(-) create mode 100644 web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/member-menu.tsx create mode 100644 web/app/components/header/account-setting/members-page/role-badges.tsx diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index c09f32ea83..14fc395d46 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -51,11 +51,19 @@ vi.mock('../invited-modal', () => ({ ), })) -vi.mock('../operation', () => ({ - default: () =>
Member Operation
, +vi.mock('../role-badges', () => ({ + default: ({ roles }: { roles: string[] }) => ( +
{roles.join(',')}
+ ), })) -vi.mock('../operation/transfer-ownership', () => ({ - default: ({ onOperate }: { onOperate: () => void }) => , +vi.mock('../member-menu', () => ({ + default: ({ member, onTransferOwnership, canTransferOwnership }: { member: Member, onTransferOwnership?: () => void, canTransferOwnership?: boolean }) => ( +
+ {canTransferOwnership && member.role === 'owner' && onTransferOwnership && ( + + )} +
+ ), })) vi.mock('../transfer-ownership-modal', () => ({ default: ({ onClose }: { onClose: () => void }) => ( diff --git a/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx b/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx new file mode 100644 index 0000000000..96a5f31ca8 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx @@ -0,0 +1,218 @@ +'use client' + +import type { Member } from '@/models/common' +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 { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Checkbox from '@/app/components/base/checkbox' +import Input from '@/app/components/base/input' + +export type AssignableRole = { + id: string + name: string + description?: string +} + +export type AssignRolesModalProps = { + open: boolean + member: Member + onClose: () => void + onSubmit: (roleIds: string[]) => void +} + +type AssignRolesModalBodyProps = { + roles: AssignableRole[] +} & Omit + +// TODO: replace with roles fetched from the permissions API once available. +const MOCK_ASSIGNABLE_ROLES: AssignableRole[] = [ + { id: 'admin', name: 'Admin', description: 'Full access to workspace management and settings' }, + { id: 'editor', name: 'Editor', description: 'Create and edit resources without settings access' }, + { id: 'member', name: 'Member', description: 'Basic workspace access' }, + { id: 'auditor', name: 'Auditor', description: 'View application logs and audit trails' }, + { id: 'tester', name: 'Tester', description: 'Test applications in sandbox environments' }, +] + +const AssignRolesModalBody = ({ + roles, + member, + onClose, + onSubmit, +}: AssignRolesModalBodyProps) => { + const { t } = useTranslation() + const [selected, setSelected] = useState(() => { + const match = MOCK_ASSIGNABLE_ROLES.find(r => r.id === member.role) + return match ? [match.id] : [] + }) + const [keyword, setKeyword] = useState('') + + const filteredRoles = useMemo(() => { + const trimmed = keyword.trim().toLowerCase() + if (!trimmed) + return roles + return roles.filter( + role => + role.name.toLowerCase().includes(trimmed) + || role.description?.toLowerCase().includes(trimmed), + ) + }, [roles, keyword]) + + const toggle = (id: string) => { + setSelected(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id], + ) + } + + const handleConfirm = () => { + onSubmit(selected) + onClose() + } + + return ( + +
+ +
+ + {t('members.assignRolesModal.title', { ns: 'common', defaultValue: 'Assign Roles' })} + + + {t('members.assignRolesModal.description', { + ns: 'common', + defaultValue: + 'Select roles to assign to this member. All permissions from selected roles will be combined.', + })} + +
+
+ +
+ setKeyword(e.target.value)} + onClear={() => setKeyword('')} + placeholder={t('members.assignRolesModal.searchPlaceholder', { + ns: 'common', + defaultValue: 'Search roles...', + })} + /> +
+ + + {filteredRoles.length === 0 + ? ( +
+ {t('members.assignRolesModal.empty', { + ns: 'common', + defaultValue: 'No matching roles', + })} +
+ ) + : ( +
    + {filteredRoles.map((role) => { + const checked = selected.includes(role.id) + const handleToggle = () => toggle(role.id) + return ( +
  • +
    { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + handleToggle() + } + }} + > + +
    +
    + {role.name} +
    + {role.description && ( +
    + {role.description} +
    + )} +
    +
    +
  • + ) + })} +
+ )} +
+ +
+
+ {t('members.assignRolesModal.selectedCount', { + ns: 'common', + count: selected.length, + defaultValue: '{{count}} selected', + })} +
+
+ + +
+
+
+ ) +} + +const AssignRolesModal = ({ + open, + member, + onClose, + onSubmit, +}: AssignRolesModalProps) => { + return ( + { + if (!nextOpen) + onClose() + }} + > + + + ) +} + +export default AssignRolesModal diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index 8fa051e852..175c7e79ae 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -19,8 +19,8 @@ import EditWorkspaceModal from './edit-workspace-modal' import InviteButton from './invite-button' import InviteModal from './invite-modal' import InvitedModal from './invited-modal' -import Operation from './operation' -import TransferOwnership from './operation/transfer-ownership' +import MemberMenu from './member-menu' +import RoleBadges from './role-badges' import TransferOwnershipModal from './transfer-ownership-modal' const MembersPage = () => { @@ -53,7 +53,9 @@ const MembersPage = () => {
- {currentWorkspace?.name[0]?.toLocaleUpperCase()} + + {currentWorkspace?.name[0]?.toLocaleUpperCase()} +
@@ -130,8 +132,16 @@ const MembersPage = () => {
{account.name} - {account.status === 'pending' && {t('members.pending', { ns: 'common' })}} - {userProfile.email === account.email && {t('members.you', { ns: 'common' })}} + {account.status === 'pending' && ( + + {t('members.pending', { ns: 'common' })} + + )} + {userProfile.email === account.email && ( + + {t('members.you', { ns: 'common' })} + + )}
{account.email}
@@ -139,7 +149,7 @@ const MembersPage = () => {
{formatTimeFromNow(Number((account.last_active_at || account.created_at)) * 1000)}
-
+ {/*
{isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && ( setShowTransferOwnershipModal(true)}> )} @@ -152,6 +162,21 @@ const MembersPage = () => { {!isCurrentWorkspaceOwner && (
{RoleMap[account.role] || RoleMap.normal}
)} +
*/} +
+ + {isCurrentWorkspaceManager && ( + setShowTransferOwnershipModal(true)} + /> + )}
)) diff --git a/web/app/components/header/account-setting/members-page/member-menu.tsx b/web/app/components/header/account-setting/members-page/member-menu.tsx new file mode 100644 index 0000000000..4dbc68d756 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/member-menu.tsx @@ -0,0 +1,134 @@ +'use client' +import type { Member } from '@/models/common' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' +import { memo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ActionButton from '@/app/components/base/action-button' +import { deleteMemberOrCancelInvitation } from '@/service/common' +import AssignRolesModal from './assign-roles-modal' + +type MemberMenuProps = { + member: Member + operatorRole: string + canTransferOwnership?: boolean + onOperate: () => void + onTransferOwnership?: () => void +} + +const MemberMenu = ({ + member, + operatorRole, + canTransferOwnership = false, + onOperate, + onTransferOwnership, +}: MemberMenuProps) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [assignModalOpen, setAssignModalOpen] = useState(false) + + const isOwner = member.role === 'owner' + const canAssignRoles + = !isOwner && (operatorRole === 'owner' || operatorRole === 'admin') + const canRemove = !isOwner + const showTransferOwnership = isOwner && canTransferOwnership + + if (!canAssignRoles && !canRemove && !showTransferOwnership) + return null + + const handleOpenAssignRoles = () => { + setOpen(false) + setAssignModalOpen(true) + } + + const handleAssignRolesSubmit = (_roleIds: string[]) => { + // TODO: wire to backend once multi-role member endpoint is ready. + toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) + onOperate() + } + + const handleRemove = async () => { + setOpen(false) + try { + await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` }) + onOperate() + toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) + } + catch { + } + } + + const handleTransferOwnership = () => { + setOpen(false) + onTransferOwnership?.() + } + + return ( + <> + + + )} + > + + + + {canAssignRoles && ( + + {t('members.assignRoles', { ns: 'common', defaultValue: 'Assign Roles' })} + + )} + {showTransferOwnership && ( + + {t('members.transferOwnership', { ns: 'common' })} + + )} + {(canAssignRoles || showTransferOwnership) && canRemove && ( + + )} + {canRemove && ( + + {t('members.removeFromTeam', { ns: 'common' })} + + )} + + + {assignModalOpen && ( + setAssignModalOpen(false)} + onSubmit={handleAssignRolesSubmit} + /> + )} + + ) +} + +export default memo(MemberMenu) diff --git a/web/app/components/header/account-setting/members-page/role-badges.tsx b/web/app/components/header/account-setting/members-page/role-badges.tsx new file mode 100644 index 0000000000..139588f901 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/role-badges.tsx @@ -0,0 +1,53 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { memo } from 'react' + +type RoleBadgeProps = { + label: string + className?: string +} + +const RoleBadge = ({ label, className }: RoleBadgeProps) => { + return ( + + {label} + + ) +} + +export type RoleBadgesProps = { + roles: string[] + max?: number + className?: string +} + +const RoleBadges = ({ roles, max = 2, className }: RoleBadgesProps) => { + if (!roles.length) + return null + + const visible = roles.slice(0, max) + const overflow = roles.slice(max) + + return ( +
+ {visible.map(role => ( + + ))} + {overflow.length > 0 && ( + + {`+${overflow.length}`} + + )} +
+ ) +} + +export default memo(RoleBadges) diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 77f31ae48c..dbc73f52b8 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -216,6 +216,12 @@ "loading": "Loading", "members.admin": "Admin", "members.adminTip": "Can build apps & manage team settings", + "members.assignRoles": "Assign Roles", + "members.assignRolesModal.description": "Select roles to assign to this member. All permissions from selected roles will be combined.", + "members.assignRolesModal.empty": "No matching roles", + "members.assignRolesModal.searchPlaceholder": "Search roles...", + "members.assignRolesModal.selectedCount": "{{count}} selected", + "members.assignRolesModal.title": "Assign Roles", "members.builder": "Builder", "members.builderTip": "Can build & edit own apps", "members.datasetOperator": "Knowledge Admin", @@ -237,6 +243,7 @@ "members.inviteTeamMemberTip": "They can access your team data directly after signing in.", "members.invitedAsRole": "Invited as {{role}} user", "members.lastActive": "LAST ACTIVE", + "members.memberActions": "Member actions", "members.name": "NAME", "members.normal": "Normal", "members.normalTip": "Only can use apps, can not build apps", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 1564d1c8e5..8c6b839ab7 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -216,6 +216,12 @@ "loading": "加载中", "members.admin": "管理员", "members.adminTip": "能够建立应用程序和管理团队设置", + "members.assignRoles": "分配角色", + "members.assignRolesModal.description": "为该成员选择要分配的角色,所选角色的权限将被合并。", + "members.assignRolesModal.empty": "没有匹配的角色", + "members.assignRolesModal.searchPlaceholder": "搜索角色…", + "members.assignRolesModal.selectedCount": "已选 {{count}} 项", + "members.assignRolesModal.title": "分配角色", "members.builder": "构建器", "members.builderTip": "可以构建和编辑自己的应用程序", "members.datasetOperator": "知识库管理员", @@ -237,6 +243,7 @@ "members.inviteTeamMemberTip": "对方在登录后可以访问你的团队数据。", "members.invitedAsRole": "邀请为{{role}}用户", "members.lastActive": "上次活动时间", + "members.memberActions": "成员操作", "members.name": "姓名", "members.normal": "成员", "members.normalTip": "只能使用应用程序,不能建立应用程序",