feat: add member management features with role assignment and member actions

This commit is contained in:
twwu 2026-04-23 18:07:12 +08:00
parent 6a2bb145e3
commit 2dc080c845
7 changed files with 462 additions and 10 deletions

View File

@ -51,11 +51,19 @@ vi.mock('../invited-modal', () => ({
</div>
),
}))
vi.mock('../operation', () => ({
default: () => <div>Member Operation</div>,
vi.mock('../role-badges', () => ({
default: ({ roles }: { roles: string[] }) => (
<div data-testid="role-badges">{roles.join(',')}</div>
),
}))
vi.mock('../operation/transfer-ownership', () => ({
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
vi.mock('../member-menu', () => ({
default: ({ member, onTransferOwnership, canTransferOwnership }: { member: Member, onTransferOwnership?: () => void, canTransferOwnership?: boolean }) => (
<div data-testid="member-menu">
{canTransferOwnership && member.role === 'owner' && onTransferOwnership && (
<button onClick={onTransferOwnership}>Transfer ownership</button>
)}
</div>
),
}))
vi.mock('../transfer-ownership-modal', () => ({
default: ({ onClose }: { onClose: () => void }) => (

View File

@ -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<AssignRolesModalProps, 'open'>
// 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<string[]>(() => {
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 (
<DialogContent
className="flex h-[484px] w-[480px] flex-col overflow-hidden p-0"
backdropProps={{ forceRender: true }}
>
<div className="relative shrink-0 px-6 pt-6 pb-4">
<DialogCloseButton />
<div className="pr-8">
<DialogTitle className="system-xl-semibold text-text-primary">
{t('members.assignRolesModal.title', { ns: 'common', defaultValue: 'Assign Roles' })}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('members.assignRolesModal.description', {
ns: 'common',
defaultValue:
'Select roles to assign to this member. All permissions from selected roles will be combined.',
})}
</DialogDescription>
</div>
</div>
<div className="shrink-0 px-6">
<Input
showLeftIcon
showClearIcon
value={keyword}
onChange={e => setKeyword(e.target.value)}
onClear={() => setKeyword('')}
placeholder={t('members.assignRolesModal.searchPlaceholder', {
ns: 'common',
defaultValue: 'Search roles...',
})}
/>
</div>
<ScrollArea
className="mt-2 min-h-0 flex-1"
slotClassNames={{ viewport: 'px-3 overscroll-contain' }}
>
{filteredRoles.length === 0
? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
{t('members.assignRolesModal.empty', {
ns: 'common',
defaultValue: 'No matching roles',
})}
</div>
)
: (
<ul className="flex flex-col gap-0.5">
{filteredRoles.map((role) => {
const checked = selected.includes(role.id)
const handleToggle = () => toggle(role.id)
return (
<li key={role.id}>
<div
role="checkbox"
aria-checked={checked}
tabIndex={0}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active',
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
)}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
handleToggle()
}
}}
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{role.name}
</div>
{role.description && (
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{role.description}
</div>
)}
</div>
</div>
</li>
)
})}
</ul>
)}
</ScrollArea>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
<div className="system-xs-regular text-text-tertiary">
{t('members.assignRolesModal.selectedCount', {
ns: 'common',
count: selected.length,
defaultValue: '{{count}} selected',
})}
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="primary" onClick={handleConfirm}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</div>
</DialogContent>
)
}
const AssignRolesModal = ({
open,
member,
onClose,
onSubmit,
}: AssignRolesModalProps) => {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
<AssignRolesModalBody
roles={MOCK_ASSIGNABLE_ROLES}
member={member}
onClose={onClose}
onSubmit={onSubmit}
/>
</Dialog>
)
}
export default AssignRolesModal

View File

@ -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 = () => {
<div className="flex flex-col">
<div className="mb-4 flex items-center gap-3 rounded-xl border-t-[0.5px] border-l-[0.5px] border-divider-subtle bg-linear-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-3 pr-5">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-components-icon-bg-blue-solid text-[20px]">
<span className="bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
<span className="bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">
{currentWorkspace?.name[0]?.toLocaleUpperCase()}
</span>
</div>
<div className="grow">
<div className="flex items-center gap-1 system-md-semibold text-text-secondary">
@ -130,8 +132,16 @@ const MembersPage = () => {
<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>}
{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>
@ -139,7 +149,7 @@ const MembersPage = () => {
<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">
{/* <div className="flex w-[215px] shrink-0 items-center">
{isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && (
<TransferOwnership onOperate={() => setShowTransferOwnershipModal(true)}></TransferOwnership>
)}
@ -152,6 +162,21 @@ const MembersPage = () => {
{!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>
))

View File

@ -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 (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={(
<ActionButton
size="l"
className={cn(open && 'bg-state-base-hover')}
aria-label={t('members.memberActions', { ns: 'common', defaultValue: 'Member 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-[180px] rounded-xl p-1"
>
{canAssignRoles && (
<DropdownMenuItem
className="system-sm-medium text-text-secondary"
onClick={handleOpenAssignRoles}
>
{t('members.assignRoles', { ns: 'common', defaultValue: 'Assign Roles' })}
</DropdownMenuItem>
)}
{showTransferOwnership && (
<DropdownMenuItem
className="system-sm-medium text-text-secondary"
onClick={handleTransferOwnership}
>
{t('members.transferOwnership', { ns: 'common' })}
</DropdownMenuItem>
)}
{(canAssignRoles || showTransferOwnership) && canRemove && (
<DropdownMenuSeparator />
)}
{canRemove && (
<DropdownMenuItem
variant="destructive"
className="system-sm-medium"
onClick={handleRemove}
>
{t('members.removeFromTeam', { ns: 'common' })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{assignModalOpen && (
<AssignRolesModal
open={assignModalOpen}
member={member}
onClose={() => setAssignModalOpen(false)}
onSubmit={handleAssignRolesSubmit}
/>
)}
</>
)
}
export default memo(MemberMenu)

View File

@ -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 (
<span
className={cn(
'inline-flex h-5 max-w-full items-center rounded-md bg-background-body px-1.5 system-xs-medium text-text-secondary shadow-xs',
className,
)}
>
<span className="truncate">{label}</span>
</span>
)
}
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 (
<div className={cn('flex min-w-0 flex-wrap items-center gap-1', className)}>
{visible.map(role => (
<RoleBadge key={role} label={role} />
))}
{overflow.length > 0 && (
<span
className="inline-flex h-5 cursor-default items-center rounded-md bg-background-body px-1.5 system-xs-medium text-text-tertiary shadow-xs"
>
{`+${overflow.length}`}
</span>
)}
</div>
)
}
export default memo(RoleBadges)

View File

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

View File

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