feat: implement member details modal and role assignment functionality

This commit is contained in:
twwu 2026-04-27 15:00:20 +08:00
parent b32ec8741e
commit 5907b3f809
8 changed files with 539 additions and 64 deletions

View File

@ -73,6 +73,16 @@ vi.mock('../transfer-ownership-modal', () => ({
</div>
),
}))
vi.mock('../member-details-modal', () => ({
default: ({ member, onClose, canAssignRoles }: { member: Member, onClose: () => void, canAssignRoles?: boolean }) => (
<div>
<div>Member Details Modal</div>
<div data-testid="details-member-name">{member.name}</div>
<div data-testid="details-can-assign">{String(canAssignRoles)}</div>
<button onClick={onClose}>Close Member Details Modal</button>
</div>
),
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: () => <div>Upgrade Button</div>,
}))
@ -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,

View File

@ -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<InvitationResult[]>([])
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<Member | null>(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 = () => {
<div className="w-[215px] shrink-0 px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.role', { ns: 'common' })}</div>
</div>
<div className="relative min-w-[480px]">
{
accounts.map(account => (
<div key={account.id} className="flex border-b border-divider-subtle">
<div className="flex grow items-center px-3 py-2">
<Avatar avatar={account.avatar_url} size="sm" className="mr-2" name={account.name} />
<div className="">
<div className="system-sm-medium text-text-secondary">
{account.name}
{account.status === 'pending' && (
<span className="ml-1 system-xs-medium text-text-warning">
{t('members.pending', { ns: 'common' })}
</span>
)}
{userProfile.email === account.email && (
<span className="system-xs-regular text-text-tertiary">
{t('members.you', { ns: 'common' })}
</span>
)}
</div>
<div className="system-xs-regular text-text-tertiary">{account.email}</div>
</div>
</div>
<div className="flex w-[120px] shrink-0 items-center py-2 system-sm-regular text-text-secondary">
{formatTimeFromNow(Number((account.last_active_at || account.created_at)) * 1000)}
</div>
{/* <div className="flex w-[215px] shrink-0 items-center">
{isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && (
<TransferOwnership onOperate={() => setShowTransferOwnershipModal(true)}></TransferOwnership>
)}
{isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && (
<div className="px-3 system-sm-regular text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
)}
{isCurrentWorkspaceOwner && account.role !== 'owner' && (
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={refetch} />
)}
{!isCurrentWorkspaceOwner && (
<div className="px-3 system-sm-regular text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
)}
</div> */}
<div className="flex w-[215px] shrink-0 items-center gap-2 px-3">
<RoleBadges
className="grow"
roles={[RoleMap[account.role] || RoleMap.normal]}
/>
{isCurrentWorkspaceManager && (
<MemberMenu
member={account}
operatorRole={currentWorkspace.role}
canTransferOwnership={isCurrentWorkspaceOwner && isAllowTransferWorkspace}
onOperate={refetch}
onTransferOwnership={() => setShowTransferOwnershipModal(true)}
/>
)}
</div>
</div>
))
}
{accounts.map(account => (
<MemberRow
key={account.id}
member={account}
roleLabel={RoleMap[account.role] || RoleMap.normal}
isCurrentUser={userProfile.email === account.email}
canManage={isCurrentWorkspaceManager}
operatorRole={currentWorkspace.role}
canTransferOwnership={isCurrentWorkspaceOwner && isAllowTransferWorkspace}
onOpenDetails={handleOpenDetails}
onOperate={refetch}
onTransferOwnership={handleTransferOwnership}
/>
))}
</div>
</div>
</div>
@ -218,6 +188,19 @@ const MembersPage = () => {
onClose={() => setShowTransferOwnershipModal(false)}
/>
)}
{detailsMember && (
<MemberDetailsModal
open={!!detailsMember}
member={detailsMember}
roleLabel={RoleMap[detailsMember.role] || RoleMap.normal}
canAssignRoles={
isCurrentWorkspaceManager
&& detailsMember.role !== 'owner'
}
onClose={() => setDetailsMember(null)}
onAssignSubmit={handleAssignRolesSubmit}
/>
)}
</>
)
}

View File

@ -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 (
<>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next)
onClose()
}}
>
<DialogContent className="w-[440px] overflow-visible p-0" backdropProps={{ forceRender: true }}>
<div className="relative px-6 pt-6 pb-5">
<DialogCloseButton />
<DialogTitle className="pr-8 system-xl-semibold text-text-primary">
{t('members.memberDetails.title', {
ns: 'common',
defaultValue: 'Member Details',
})}
</DialogTitle>
<div className="mt-5 flex items-center gap-3">
<Avatar
avatar={member.avatar_url}
name={member.name}
size="2xl"
/>
<div className="min-w-0 flex-1">
<div className="truncate system-md-semibold text-text-primary">
{member.name}
</div>
<div className="truncate system-xs-regular text-text-tertiary">
{member.email}
</div>
</div>
</div>
</div>
<div className="border-t border-divider-subtle px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 system-sm-semibold text-text-secondary">
<span>
{t('members.memberDetails.assignedRoles', {
ns: 'common',
defaultValue: 'Assigned Roles',
})}
</span>
<span className="system-xs-medium text-text-tertiary">
{assignedRoles.length}
</span>
</div>
{canAssignRoles && (
<Button
variant="ghost"
size="small"
onClick={() => setAssignOpen(true)}
>
<span
aria-hidden
className="mr-0.5 i-ri-add-line h-3.5 w-3.5"
/>
{t('members.memberDetails.assign', {
ns: 'common',
defaultValue: 'Assign',
})}
</Button>
)}
</div>
<div className="mt-4">
<div className="mb-2 system-2xs-medium-uppercase text-text-tertiary">
{t('members.memberDetails.generalGroup', {
ns: 'common',
defaultValue: 'GENERAL',
})}
</div>
<div className="flex flex-wrap gap-1.5">
{assignedRoles.map(role => (
<PermissionRoleChip
key={role.key}
roleKey={role.key}
label={role.label}
highlighted
/>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
{assignOpen && (
<AssignRolesModal
open={assignOpen}
member={member}
onClose={() => setAssignOpen(false)}
onSubmit={handleAssignSubmit}
/>
)}
</>
)
}
export default memo(MemberDetailsModal)

View File

@ -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 = (
<span
className={cn(
'inline-flex h-6 max-w-full cursor-default items-center gap-1 rounded-md px-1.5 system-xs-medium shadow-xs',
highlighted
? 'bg-state-accent-hover text-text-accent'
: 'bg-background-body text-text-secondary',
className,
)}
data-testid="permission-role-chip"
data-role-key={roleKey}
>
<span className="truncate">{label}</span>
{onRemove && (
<button
type="button"
aria-label={t('members.memberDetails.removeRoleAria', {
ns: 'common',
role: label,
defaultValue: 'Remove {{role}} role',
})}
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className={cn(
'flex h-4 w-4 items-center justify-center rounded hover:bg-black/5',
highlighted ? 'text-text-accent' : 'text-text-tertiary',
)}
>
<span aria-hidden className="i-ri-close-line h-3 w-3" />
</button>
)}
</span>
)
if (!hasPermissions)
return chip
return (
<PreviewCard>
<PreviewCardTrigger render={chip} />
<PreviewCardContent
placement="bottom-start"
popupClassName="min-w-[200px] max-w-[280px] p-3"
>
<div className="mb-2 system-sm-semibold text-text-accent">
{label}
</div>
<ul className="flex flex-col gap-1.5 system-xs-regular text-text-secondary">
{permissions.map(key => (
<li key={key} className="flex items-start gap-2">
<span
aria-hidden
className="mt-[7px] inline-block h-1 w-1 shrink-0 rounded-full bg-text-tertiary"
/>
<span>
{t(`members.memberDetails.permissions.${key}`, {
ns: 'common',
defaultValue: key,
})}
</span>
</li>
))}
</ul>
</PreviewCardContent>
</PreviewCard>
)
}
export default memo(PermissionRoleChip)

View File

@ -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<string, string[]> = {
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] ?? []
}

View File

@ -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<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openDetails()
}
}, [openDetails])
const stopPropagationOnClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
}, [])
const stopPropagationOnKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ')
e.stopPropagation()
}, [])
return (
<div
role="button"
tabIndex={0}
data-testid={`member-row-${member.id}`}
aria-label={t('members.memberDetails.openAria', {
ns: 'common',
name: member.name,
defaultValue: 'Open member details for {{name}}',
})}
className="flex cursor-pointer border-b border-divider-subtle hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
onClick={openDetails}
onKeyDown={handleRowKeyDown}
>
<div className="flex grow items-center px-3 py-2">
<Avatar avatar={member.avatar_url} size="sm" className="mr-2" name={member.name} />
<div className="">
<div className="system-sm-medium text-text-secondary">
{member.name}
{member.status === 'pending' && (
<span className="ml-1 system-xs-medium text-text-warning">
{t('members.pending', { ns: 'common' })}
</span>
)}
{isCurrentUser && (
<span className="system-xs-regular text-text-tertiary">
{t('members.you', { ns: 'common' })}
</span>
)}
</div>
<div className="system-xs-regular text-text-tertiary">{member.email}</div>
</div>
</div>
<div className="flex w-[120px] shrink-0 items-center py-2 system-sm-regular text-text-secondary">
{formatTimeFromNow(Number((member.last_active_at || member.created_at)) * 1000)}
</div>
<div
className="flex w-[215px] shrink-0 items-center gap-2 px-3"
onClick={stopPropagationOnClick}
onKeyDown={stopPropagationOnKeyDown}
role="presentation"
>
<RoleBadges
className="grow"
roles={[roleLabel]}
/>
{canManage && (
<MemberMenu
member={member}
operatorRole={operatorRole}
canTransferOwnership={canTransferOwnership}
onOperate={onOperate}
onTransferOwnership={onTransferOwnership}
/>
)}
</div>
</div>
)
}
export default memo(MemberRow)

View File

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

View File

@ -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": "只能使用应用程序,不能建立应用程序",