mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
feat: add member management features with role assignment and member actions
This commit is contained in:
parent
6a2bb145e3
commit
2dc080c845
@ -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 }) => (
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
))
|
||||
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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",
|
||||
|
||||
@ -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": "只能使用应用程序,不能建立应用程序",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user