From 5907b3f8099400fc3de93166a0b9617a3753d9b2 Mon Sep 17 00:00:00 2001 From: twwu Date: Mon, 27 Apr 2026 15:00:20 +0800 Subject: [PATCH] feat: implement member details modal and role assignment functionality --- .../members-page/__tests__/index.spec.tsx | 56 +++++++ .../account-setting/members-page/index.tsx | 111 ++++++-------- .../member-details-modal/index.tsx | 145 ++++++++++++++++++ .../permission-role-chip.tsx | 102 ++++++++++++ .../member-details-modal/role-permissions.ts | 36 +++++ .../members-page/member-row.tsx | 117 ++++++++++++++ web/i18n/en-US/common.json | 18 +++ web/i18n/zh-Hans/common.json | 18 +++ 8 files changed, 539 insertions(+), 64 deletions(-) create mode 100644 web/app/components/header/account-setting/members-page/member-details-modal/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/member-details-modal/permission-role-chip.tsx create mode 100644 web/app/components/header/account-setting/members-page/member-details-modal/role-permissions.ts create mode 100644 web/app/components/header/account-setting/members-page/member-row.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 14fc395d46..60b5937cf5 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 @@ -73,6 +73,16 @@ vi.mock('../transfer-ownership-modal', () => ({ ), })) +vi.mock('../member-details-modal', () => ({ + default: ({ member, onClose, canAssignRoles }: { member: Member, onClose: () => void, canAssignRoles?: boolean }) => ( +
+
Member Details Modal
+
{member.name}
+
{String(canAssignRoles)}
+ +
+ ), +})) vi.mock('@/app/components/billing/upgrade-btn', () => ({ default: () =>
Upgrade Button
, })) @@ -368,6 +378,52 @@ describe('MembersPage', () => { expect(screen.getByText('common.members.normal'))!.toBeInTheDocument() }) + it('should open member details modal when a member row is clicked', async () => { + const user = userEvent.setup() + + renderMembersPage() + + await user.click(screen.getByTestId('member-row-2')) + + expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument() + expect(screen.getByTestId('details-member-name'))!.toHaveTextContent('Admin User') + + await user.click(screen.getByRole('button', { name: 'Close Member Details Modal' })) + expect(screen.queryByText('Member Details Modal')).not.toBeInTheDocument() + }) + + it('should open member details modal via keyboard Enter', async () => { + const user = userEvent.setup() + + renderMembersPage() + + const row = screen.getByTestId('member-row-2') + row.focus() + await user.keyboard('{Enter}') + + expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument() + }) + + it('should not allow assigning roles from member details when target is owner', async () => { + const user = userEvent.setup() + + renderMembersPage() + + await user.click(screen.getByTestId('member-row-1')) + + expect(screen.getByTestId('details-can-assign'))!.toHaveTextContent('false') + }) + + it('should not open member details when clicking the member menu area', async () => { + const user = userEvent.setup() + + renderMembersPage() + + await user.click(screen.getByRole('button', { name: /transfer ownership/i })) + + expect(screen.queryByText('Member Details Modal')).not.toBeInTheDocument() + }) + it('should show upgrade button when member limit is full', () => { vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({ enableBilling: true, 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 175c7e79ae..51ceabf90f 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -1,9 +1,9 @@ 'use client' -import type { InvitationResult } from '@/models/common' -import { Avatar } from '@langgenius/dify-ui/avatar' +import type { InvitationResult, Member } from '@/models/common' +import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useSuspenseQuery } from '@tanstack/react-query' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { NUM_INFINITE } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' @@ -11,7 +11,6 @@ import UpgradeBtn from '@/app/components/billing/upgrade-btn' import { useAppContext } from '@/context/app-context' import { useLocale } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' -import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { LanguagesSupported } from '@/i18n-config/language' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useMembers } from '@/service/use-common' @@ -19,8 +18,8 @@ import EditWorkspaceModal from './edit-workspace-modal' import InviteButton from './invite-button' import InviteModal from './invite-modal' import InvitedModal from './invited-modal' -import MemberMenu from './member-menu' -import RoleBadges from './role-badges' +import MemberDetailsModal from './member-details-modal' +import MemberRow from './member-row' import TransferOwnershipModal from './transfer-ownership-modal' const MembersPage = () => { @@ -37,7 +36,6 @@ const MembersPage = () => { const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext() const { data, refetch } = useMembers() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { formatTimeFromNow } = useFormatTimeFromNow() const [inviteModalVisible, setInviteModalVisible] = useState(false) const [invitationResults, setInvitationResults] = useState([]) const [invitedModalVisible, setInvitedModalVisible] = useState(false) @@ -47,6 +45,21 @@ const MembersPage = () => { const isMemberFull = enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false) const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false) + const [detailsMember, setDetailsMember] = useState(null) + + const handleAssignRolesSubmit = (_roleIds: string[]) => { + // TODO: wire to backend once multi-role member endpoint is ready. + toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) + refetch() + } + + const handleOpenDetails = useCallback((member: Member) => { + setDetailsMember(member) + }, []) + + const handleTransferOwnership = useCallback(() => { + setShowTransferOwnershipModal(true) + }, []) return ( <> @@ -124,63 +137,20 @@ const MembersPage = () => {
{t('members.role', { ns: 'common' })}
- { - accounts.map(account => ( -
-
- -
-
- {account.name} - {account.status === 'pending' && ( - - {t('members.pending', { ns: 'common' })} - - )} - {userProfile.email === account.email && ( - - {t('members.you', { ns: 'common' })} - - )} -
-
{account.email}
-
-
-
- {formatTimeFromNow(Number((account.last_active_at || account.created_at)) * 1000)} -
- {/*
- {isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && ( - setShowTransferOwnershipModal(true)}> - )} - {isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && ( -
{RoleMap[account.role] || RoleMap.normal}
- )} - {isCurrentWorkspaceOwner && account.role !== 'owner' && ( - - )} - {!isCurrentWorkspaceOwner && ( -
{RoleMap[account.role] || RoleMap.normal}
- )} -
*/} -
- - {isCurrentWorkspaceManager && ( - setShowTransferOwnershipModal(true)} - /> - )} -
-
- )) - } + {accounts.map(account => ( + + ))}
@@ -218,6 +188,19 @@ const MembersPage = () => { onClose={() => setShowTransferOwnershipModal(false)} /> )} + {detailsMember && ( + setDetailsMember(null)} + onAssignSubmit={handleAssignRolesSubmit} + /> + )} ) } diff --git a/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx b/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx new file mode 100644 index 0000000000..267da85b1e --- /dev/null +++ b/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx @@ -0,0 +1,145 @@ +'use client' + +import type { Member } from '@/models/common' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { memo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AssignRolesModal from '../assign-roles-modal' +import PermissionRoleChip from './permission-role-chip' + +export type MemberDetailsModalProps = { + open: boolean + member: Member + roleLabel: string + canAssignRoles?: boolean + onClose: () => void + onAssignSubmit?: (roleIds: string[]) => void +} + +const MemberDetailsModal = ({ + open, + member, + roleLabel, + canAssignRoles = false, + onClose, + onAssignSubmit, +}: MemberDetailsModalProps) => { + const { t } = useTranslation() + const [assignOpen, setAssignOpen] = useState(false) + + const assignedRoles = [{ key: member.role, label: roleLabel }] + + const handleAssignSubmit = (ids: string[]) => { + onAssignSubmit?.(ids) + setAssignOpen(false) + } + + return ( + <> + { + if (!next) + onClose() + }} + > + +
+ + + {t('members.memberDetails.title', { + ns: 'common', + defaultValue: 'Member Details', + })} + + +
+ +
+
+ {member.name} +
+
+ {member.email} +
+
+
+
+ +
+
+
+ + {t('members.memberDetails.assignedRoles', { + ns: 'common', + defaultValue: 'Assigned Roles', + })} + + + {assignedRoles.length} + +
+ {canAssignRoles && ( + + )} +
+ +
+
+ {t('members.memberDetails.generalGroup', { + ns: 'common', + defaultValue: 'GENERAL', + })} +
+
+ {assignedRoles.map(role => ( + + ))} +
+
+
+
+
+ + {assignOpen && ( + setAssignOpen(false)} + onSubmit={handleAssignSubmit} + /> + )} + + ) +} + +export default memo(MemberDetailsModal) diff --git a/web/app/components/header/account-setting/members-page/member-details-modal/permission-role-chip.tsx b/web/app/components/header/account-setting/members-page/member-details-modal/permission-role-chip.tsx new file mode 100644 index 0000000000..d6a17de6b7 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/member-details-modal/permission-role-chip.tsx @@ -0,0 +1,102 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { + PreviewCard, + PreviewCardContent, + PreviewCardTrigger, +} from '@langgenius/dify-ui/preview-card' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { getRolePermissionKeys } from './role-permissions' + +export type PermissionRoleChipProps = { + roleKey: string + label: string + highlighted?: boolean + onRemove?: () => void + className?: string +} + +const PermissionRoleChip = ({ + roleKey, + label, + highlighted = false, + onRemove, + className, +}: PermissionRoleChipProps) => { + const { t } = useTranslation() + const permissions = getRolePermissionKeys(roleKey) + const hasPermissions = permissions.length > 0 + + const chip = ( + + {label} + {onRemove && ( + + )} + + ) + + if (!hasPermissions) + return chip + + return ( + + + +
+ {label} +
+
    + {permissions.map(key => ( +
  • + + + {t(`members.memberDetails.permissions.${key}`, { + ns: 'common', + defaultValue: key, + })} + +
  • + ))} +
+
+
+ ) +} + +export default memo(PermissionRoleChip) diff --git a/web/app/components/header/account-setting/members-page/member-details-modal/role-permissions.ts b/web/app/components/header/account-setting/members-page/member-details-modal/role-permissions.ts new file mode 100644 index 0000000000..c090a7f8a0 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/member-details-modal/role-permissions.ts @@ -0,0 +1,36 @@ +// TODO: replace with permissions fetched from the permissions API once available. +// Mock mapping from a workspace role key to the list of i18n keys describing +// what permission points that role grants. +export const ROLE_PERMISSION_KEYS: Record = { + owner: [ + 'inviteMembers', + 'removeMembers', + 'assignRoles', + 'workspaceSettings', + 'manageBilling', + 'transferOwnership', + ], + admin: [ + 'inviteMembers', + 'removeMembers', + 'assignRoles', + 'workspaceSettings', + 'manageBilling', + ], + editor: [ + 'createApps', + 'editApps', + 'createDatasets', + 'editDatasets', + ], + dataset_operator: [ + 'manageDatasets', + ], + normal: [ + 'useApps', + ], +} + +export const getRolePermissionKeys = (roleKey: string): string[] => { + return ROLE_PERMISSION_KEYS[roleKey] ?? [] +} diff --git a/web/app/components/header/account-setting/members-page/member-row.tsx b/web/app/components/header/account-setting/members-page/member-row.tsx new file mode 100644 index 0000000000..6297e66f21 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/member-row.tsx @@ -0,0 +1,117 @@ +'use client' +import type { KeyboardEvent } from 'react' +import type { Member } from '@/models/common' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import MemberMenu from './member-menu' +import RoleBadges from './role-badges' + +type MemberRowProps = { + member: Member + roleLabel: string + isCurrentUser: boolean + canManage: boolean + operatorRole: string + canTransferOwnership: boolean + onOpenDetails: (member: Member) => void + onOperate: () => void + onTransferOwnership: () => void +} + +const MemberRow = ({ + member, + roleLabel, + isCurrentUser, + canManage, + operatorRole, + canTransferOwnership, + onOpenDetails, + onOperate, + onTransferOwnership, +}: MemberRowProps) => { + const { t } = useTranslation() + const { formatTimeFromNow } = useFormatTimeFromNow() + + const openDetails = useCallback(() => { + onOpenDetails(member) + }, [member, onOpenDetails]) + + const handleRowKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + openDetails() + } + }, [openDetails]) + + const stopPropagationOnClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + }, []) + + const stopPropagationOnKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') + e.stopPropagation() + }, []) + + return ( +
+
+ +
+
+ {member.name} + {member.status === 'pending' && ( + + {t('members.pending', { ns: 'common' })} + + )} + {isCurrentUser && ( + + {t('members.you', { ns: 'common' })} + + )} +
+
{member.email}
+
+
+
+ {formatTimeFromNow(Number((member.last_active_at || member.created_at)) * 1000)} +
+
+ + {canManage && ( + + )} +
+
+ ) +} + +export default memo(MemberRow) diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index dbc73f52b8..5921348b9e 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -244,6 +244,24 @@ "members.invitedAsRole": "Invited as {{role}} user", "members.lastActive": "LAST ACTIVE", "members.memberActions": "Member actions", + "members.memberDetails.assign": "Assign", + "members.memberDetails.assignedRoles": "Assigned Roles", + "members.memberDetails.generalGroup": "GENERAL", + "members.memberDetails.openAria": "Open member details for {{name}}", + "members.memberDetails.permissions.assignRoles": "Assign roles", + "members.memberDetails.permissions.createApps": "Create apps", + "members.memberDetails.permissions.createDatasets": "Create knowledge", + "members.memberDetails.permissions.editApps": "Edit apps", + "members.memberDetails.permissions.editDatasets": "Edit knowledge", + "members.memberDetails.permissions.inviteMembers": "Invite members", + "members.memberDetails.permissions.manageBilling": "Manage billing", + "members.memberDetails.permissions.manageDatasets": "Manage knowledge", + "members.memberDetails.permissions.removeMembers": "Remove members", + "members.memberDetails.permissions.transferOwnership": "Transfer ownership", + "members.memberDetails.permissions.useApps": "Use apps", + "members.memberDetails.permissions.workspaceSettings": "Workspace settings", + "members.memberDetails.removeRoleAria": "Remove {{role}} role", + "members.memberDetails.title": "Member Details", "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 8c6b839ab7..12d9ae9ffa 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -244,6 +244,24 @@ "members.invitedAsRole": "邀请为{{role}}用户", "members.lastActive": "上次活动时间", "members.memberActions": "成员操作", + "members.memberDetails.assign": "分配", + "members.memberDetails.assignedRoles": "已分配角色", + "members.memberDetails.generalGroup": "通用角色", + "members.memberDetails.openAria": "打开 {{name}} 的成员详情", + "members.memberDetails.permissions.assignRoles": "分配角色", + "members.memberDetails.permissions.createApps": "创建应用", + "members.memberDetails.permissions.createDatasets": "创建知识库", + "members.memberDetails.permissions.editApps": "编辑应用", + "members.memberDetails.permissions.editDatasets": "编辑知识库", + "members.memberDetails.permissions.inviteMembers": "邀请成员", + "members.memberDetails.permissions.manageBilling": "管理订阅", + "members.memberDetails.permissions.manageDatasets": "管理知识库", + "members.memberDetails.permissions.removeMembers": "移除成员", + "members.memberDetails.permissions.transferOwnership": "转移所有权", + "members.memberDetails.permissions.useApps": "使用应用", + "members.memberDetails.permissions.workspaceSettings": "工作空间设置", + "members.memberDetails.removeRoleAria": "移除 {{role}} 角色", + "members.memberDetails.title": "成员详情", "members.name": "姓名", "members.normal": "成员", "members.normalTip": "只能使用应用程序,不能建立应用程序",