mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
chore: admin also has the permission of changing role (#36069)
Co-authored-by: Yansong Zhang <916125788@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Yansong Zhang
autofix-ci[bot]
parent
c26be9d3f4
commit
680ef077ae
@@ -52,7 +52,13 @@ vi.mock('../invited-modal', () => ({
|
||||
),
|
||||
}))
|
||||
vi.mock('../operation', () => ({
|
||||
default: () => <div>Member Operation</div>,
|
||||
default: ({ member }: { member: Member }) => (
|
||||
<div>
|
||||
Member Operation
|
||||
{' '}
|
||||
{member.role}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('../operation/transfer-ownership', () => ({
|
||||
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
|
||||
@@ -296,6 +302,37 @@ describe('MembersPage', () => {
|
||||
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow admins to operate other non-owner members only', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
userProfile: { email: 'admin@example.com' },
|
||||
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceManager: true,
|
||||
} as unknown as AppContextValue)
|
||||
vi.mocked(useMembers).mockReturnValue({
|
||||
data: {
|
||||
accounts: [
|
||||
mockAccounts[0],
|
||||
mockAccounts[1],
|
||||
{ ...mockAccounts[1]!, id: '3', email: 'editor@example.com', name: 'Editor User', role: 'editor' },
|
||||
{ ...mockAccounts[1]!, id: '4', email: 'normal@example.com', name: 'Normal User', role: 'normal' },
|
||||
{ ...mockAccounts[1]!, id: '5', email: 'dataset@example.com', name: 'Dataset User', role: 'dataset_operator' },
|
||||
{ ...mockAccounts[1]!, id: '6', email: 'other-admin@example.com', name: 'Other Admin User', role: 'admin' },
|
||||
],
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
expect(screen.getByText('Member Operation editor'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Member Operation normal'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Member Operation dataset_operator'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Member Operation admin'))!.toBeInTheDocument()
|
||||
expect(screen.getAllByText('common.members.admin')).toHaveLength(1)
|
||||
expect(screen.queryByText('Member Operation owner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use created_at as fallback when last_active_at is empty', () => {
|
||||
const memberNoLastActive: Member = {
|
||||
...mockAccounts[1]!,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import type { InvitationResult, Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
@@ -47,6 +47,12 @@ const MembersPage = () => {
|
||||
const isMemberFull = enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers
|
||||
const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false)
|
||||
const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false)
|
||||
const canOperateMember = (account: Member) => {
|
||||
if (isCurrentWorkspaceOwner)
|
||||
return account.role !== 'owner'
|
||||
|
||||
return currentWorkspace.role === 'admin' && account.role !== 'owner' && account.email !== userProfile.email
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -146,10 +152,10 @@ const MembersPage = () => {
|
||||
{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' && (
|
||||
{account.role !== 'owner' && canOperateMember(account) && (
|
||||
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={refetch} />
|
||||
)}
|
||||
{!isCurrentWorkspaceOwner && (
|
||||
{account.role !== 'owner' && !canOperateMember(account) && (
|
||||
<div className="px-3 system-sm-regular text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+17
-9
@@ -8,8 +8,8 @@ const mockUpdateMemberRole = vi.fn()
|
||||
const mockDeleteMemberOrCancelInvitation = vi.fn()
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
deleteMemberOrCancelInvitation: () => mockDeleteMemberOrCancelInvitation(),
|
||||
updateMemberRole: () => mockUpdateMemberRole(),
|
||||
deleteMemberOrCancelInvitation: (args: unknown) => mockDeleteMemberOrCancelInvitation(args),
|
||||
updateMemberRole: (args: unknown) => mockUpdateMemberRole(args),
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.fn(() => ({
|
||||
@@ -65,18 +65,21 @@ describe('Operation', () => {
|
||||
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show owner-allowed role options when operator role is admin', async () => {
|
||||
it('should show admin-allowed role options when operator role is admin', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOperation({}, 'admin')
|
||||
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
|
||||
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.members.admin')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('common.members.editor')).toHaveLength(2)
|
||||
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show role options when operator role is unsupported', async () => {
|
||||
it('should not show role options or remove action when operator role is unsupported', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOperation({}, 'normal')
|
||||
@@ -84,7 +87,7 @@ describe('Operation', () => {
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
|
||||
expect(screen.queryByText('common.members.normal')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.members.removeFromTeam')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call updateMemberRole and onOperate when selecting another role', async () => {
|
||||
@@ -96,7 +99,10 @@ describe('Operation', () => {
|
||||
await user.click(await screen.findByText('common.members.normal'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMemberRole).toHaveBeenCalled()
|
||||
expect(mockUpdateMemberRole).toHaveBeenCalledWith({
|
||||
url: '/workspaces/current/members/member-id/update-role',
|
||||
body: { role: 'normal' },
|
||||
})
|
||||
expect(onOperate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -109,7 +115,7 @@ describe('Operation', () => {
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
|
||||
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.members.admin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to normal role label when member role is unknown', () => {
|
||||
@@ -127,7 +133,9 @@ describe('Operation', () => {
|
||||
await user.click(await screen.findByText('common.members.removeFromTeam'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()
|
||||
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalledWith({
|
||||
url: '/workspaces/current/members/member-id',
|
||||
})
|
||||
expect(onOperate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,9 @@ const roleI18nKeyMap = {
|
||||
dataset_operator: { label: 'members.datasetOperator', tip: 'members.datasetOperatorTip' },
|
||||
} as const
|
||||
type OperationRoleKey = keyof typeof roleI18nKeyMap
|
||||
const nonOwnerRoles = ['admin', 'editor', 'normal'] as const
|
||||
const isNonOwnerRole = (role: Member['role']) => role !== 'owner'
|
||||
|
||||
const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
@@ -48,13 +51,13 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
|
||||
}
|
||||
if (operatorRole === 'admin') {
|
||||
return [
|
||||
'editor',
|
||||
'normal',
|
||||
...nonOwnerRoles,
|
||||
...(datasetOperatorEnabled ? ['dataset_operator'] as const : []),
|
||||
]
|
||||
}
|
||||
return []
|
||||
}, [operatorRole, datasetOperatorEnabled])
|
||||
const canRemoveMember = operatorRole === 'owner' || (operatorRole === 'admin' && isNonOwnerRole(member.role))
|
||||
const handleDeleteMemberOrCancelInvitation = async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
@@ -81,7 +84,7 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
render={<div className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')} />}
|
||||
render={<button type="button" className={cn('group flex h-full w-full cursor-pointer items-center justify-between border-none bg-transparent px-3 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')} />}
|
||||
>
|
||||
{RoleMap[member.role] || RoleMap.normal}
|
||||
<span aria-hidden className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
@@ -108,19 +111,23 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem
|
||||
className="h-auto items-start gap-2 rounded-lg px-3 py-2"
|
||||
onClick={handleDeleteMemberOrCancelInvitation}
|
||||
>
|
||||
<span aria-hidden className="mt-[2px] h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
|
||||
{canRemoveMember && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem
|
||||
className="h-auto items-start gap-2 rounded-lg px-3 py-2"
|
||||
onClick={handleDeleteMemberOrCancelInvitation}
|
||||
>
|
||||
<span aria-hidden className="mt-[2px] h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user