fix(web): restore permission selector semantics (#39269)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
yyh 2026-07-20 14:30:09 +08:00 committed by GitHub
parent 38ea0de907
commit ba24745c07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 673 additions and 1257 deletions

View File

@ -1889,22 +1889,6 @@
"count": 2
}
},
"web/app/components/base/permission-selector/member-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/base/permission-selector/permission-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/base/prompt-editor/index.stories.tsx": {
"jsx_a11y/label-has-associated-control": {
"count": 4
@ -2956,22 +2940,6 @@
"count": 1
}
},
"web/app/components/datasets/settings/permission-selector/member-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/settings/permission-selector/permission-item.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/develop/code.tsx": {
"typescript/no-explicit-any": {
"count": 6

View File

@ -1,295 +0,0 @@
import type { Member } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
// oxlint-disable-next-line no-restricted-imports -- This legacy selector still relies on showLeftIcon/showClearIcon props from the old input.
import Input from '@/app/components/base/input'
import { userProfileAtom } from '@/context/account-state'
import { PermissionLevel } from '@/models/permission'
import MemberItem from './member-item'
import Item from './permission-item'
type PermissionSelectorProps = {
disabled?: boolean
permission?: PermissionLevel
value: string[]
memberList: Member[]
onChange: (permission?: PermissionLevel) => void
onMemberSelect: (v: string[]) => void
/** i18n namespace for label strings (defaults to datasetSettings for backward compat) */
i18nNamespace?: 'datasetSettings'
/**
* Hide the "Partial members" option. Useful for surfaces (e.g. plugin
* credential creation) where partial-member access is delegated to RBAC
* and the picker should only expose only_me / all_team_members.
*/
hidePartialMembers?: boolean
}
const PermissionSelector = ({
disabled,
permission,
value,
memberList,
onChange,
onMemberSelect,
i18nNamespace = 'datasetSettings',
hidePartialMembers = false,
}: PermissionSelectorProps) => {
const { t } = useTranslation()
const userProfile = useAtomValue(userProfileAtom)
const [open, setOpen] = useState(false)
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(
() => {
setSearchKeywords(keywords)
},
{ wait: 500 },
)
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const selectMember = useCallback(
(member: Member) => {
if (value.includes(member.id)) onMemberSelect(value.filter((v) => v !== member.id))
else onMemberSelect([...value, member.id])
},
[value, onMemberSelect],
)
const selectedMembers = useMemo(() => {
return [
userProfile,
...memberList
.filter((member) => member.id !== userProfile.id)
.filter((member) => value.includes(member.id)),
]
}, [userProfile, value, memberList])
const showMe = useMemo(() => {
return (
(userProfile.name ?? '').includes(searchKeywords) ||
(userProfile.email ?? '').includes(searchKeywords)
)
}, [searchKeywords, userProfile])
const filteredMemberList = useMemo(() => {
return memberList.filter(
(member) =>
(member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) &&
member.id !== userProfile.id &&
['owner', 'admin', 'editor', 'dataset_operator'].includes(member.role),
)
}, [memberList, searchKeywords, userProfile])
const onSelectOnlyMe = useCallback(() => {
onChange(PermissionLevel.onlyMe)
setOpen(false)
}, [onChange])
const onSelectAllMembers = useCallback(() => {
onChange(PermissionLevel.allTeamMembers)
setOpen(false)
}, [onChange])
const onSelectPartialMembers = useCallback(() => {
onChange(PermissionLevel.partialMembers)
onMemberSelect([userProfile.id])
}, [onChange, onMemberSelect, userProfile])
const isOnlyMe = permission === PermissionLevel.onlyMe
const isAllTeamMembers = permission === PermissionLevel.allTeamMembers
const isPartialMembers = permission === PermissionLevel.partialMembers
const selectedMemberNames = selectedMembers.map((member) => member.name).join(', ')
return (
<Popover open={open} onOpenChange={setOpen}>
<div className="relative">
<PopoverTrigger
disabled={disabled}
render={
<div
className={cn(
'flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
disabled &&
'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!',
)}
/>
}
>
{isOnlyMe && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t(($) => $['form.permissionsOnlyMe'], { ns: i18nNamespace })}
</div>
</>
)}
{isAllTeamMembers && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<RiGroup2Line className="size-4 text-text-secondary" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t(($) => $['form.permissionsAllMember'], { ns: i18nNamespace })}
</div>
</>
)}
{isPartialMembers && (
<>
<div className="relative flex size-6 shrink-0 items-center justify-center">
{selectedMembers.length === 1 && (
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
size="xs"
/>
)}
{selectedMembers.length >= 2 && (
<>
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
className="absolute top-0 left-0 z-0"
size="xxs"
/>
<Avatar
avatar={selectedMembers[1]!.avatar_url}
name={selectedMembers[1]!.name}
className="absolute right-0 bottom-0 z-10"
size="xxs"
/>
</>
)}
</div>
<div
title={selectedMemberNames}
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
>
{selectedMemberNames}
</div>
</>
)}
<RiArrowDownSLine
className={cn(
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
disabled && 'text-components-input-text-placeholder!',
)}
/>
</PopoverTrigger>
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[480px] p-0">
<div className="p-1">
{/* Only me */}
<Item
leftIcon={
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
className="shrink-0"
size="sm"
/>
}
text={t(($) => $['form.permissionsOnlyMe'], { ns: i18nNamespace })}
onClick={onSelectOnlyMe}
isSelected={isOnlyMe}
/>
{/* All team members */}
<Item
leftIcon={
<div className="flex size-6 shrink-0 items-center justify-center">
<RiGroup2Line className="size-4 text-text-secondary" />
</div>
}
text={t(($) => $['form.permissionsAllMember'], { ns: i18nNamespace })}
onClick={onSelectAllMembers}
isSelected={isAllTeamMembers}
/>
{/* Partial members */}
{!hidePartialMembers && (
<Item
leftIcon={
<div className="flex size-6 shrink-0 items-center justify-center">
<RiLock2Line className="size-4 text-text-secondary" />
</div>
}
text={t(($) => $['form.permissionsInvitedMembers'], { ns: i18nNamespace })}
onClick={onSelectPartialMembers}
isSelected={isPartialMembers}
/>
)}
</div>
{!hidePartialMembers && isPartialMembers && (
<div className="max-h-[360px] overflow-y-auto border-t border-divider-regular pr-1 pb-1 pl-1">
<div className="sticky top-0 left-0 z-10 bg-components-panel-on-panel-item-bg p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={(e) => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
<div className="flex flex-col p-1">
{showMe && (
<MemberItem
leftIcon={
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
className="shrink-0"
size="sm"
/>
}
name={userProfile.name}
email={userProfile.email}
isSelected
isMe
i18nNamespace={i18nNamespace}
/>
)}
{filteredMemberList.map((member) => (
<MemberItem
key={member.id}
leftIcon={
<Avatar
avatar={member.avatar_url}
name={member.name}
className="shrink-0"
size="sm"
/>
}
name={member.name}
email={member.email}
isSelected={value.includes(member.id)}
onClick={selectMember.bind(null, member)}
i18nNamespace={i18nNamespace}
/>
))}
{!showMe && filteredMemberList.length === 0 && (
<div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary">
{t(($) => $['form.onSearchResults'], { ns: i18nNamespace })}
</div>
)}
</div>
</div>
)}
</PopoverContent>
</div>
</Popover>
)
}
export default PermissionSelector

View File

@ -1,51 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type MemberItemProps = {
leftIcon: React.ReactNode
name: string
email: string
isSelected: boolean
isMe?: boolean
onClick?: () => void
i18nNamespace?: 'datasetSettings'
}
const MemberItem = ({
leftIcon,
name,
email,
isSelected,
isMe = false,
onClick,
i18nNamespace = 'datasetSettings',
}: MemberItemProps) => {
const { t } = useTranslation()
return (
<div
className="flex cursor-pointer items-center gap-2 rounded-lg py-1 pr-[10px] pl-2 hover:bg-state-base-hover"
onClick={onClick}
>
{leftIcon}
<div className="grow">
<div className="truncate system-sm-medium text-text-secondary">
{name}
{isMe && (
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['form.me'], { ns: i18nNamespace })}
</span>
)}
</div>
<div className="truncate system-xs-regular text-text-tertiary">{email}</div>
</div>
{isSelected && (
<RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} />
)}
</div>
)
}
export default React.memo(MemberItem)

View File

@ -1,24 +0,0 @@
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
type PermissionItemProps = {
leftIcon: React.ReactNode
text: string
onClick: () => void
isSelected: boolean
}
const PermissionItem = ({ leftIcon, text, onClick, isSelected }: PermissionItemProps) => {
return (
<div
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover"
onClick={onClick}
>
{leftIcon}
<div className="grow px-1 system-md-regular text-text-secondary">{text}</div>
{isSelected && <RiCheckLine className="size-4 text-text-accent" />}
</div>
)
}
export default React.memo(PermissionItem)

View File

@ -1,574 +1,228 @@
import type { ComponentProps, ReactNode } from 'react'
import type { Member } from '@/models/common'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Provider } from 'jotai'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { defaultSystemFeatures } from '@/features/system-features/config'
import { DatasetPermission } from '@/models/datasets'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createQueryAtomTestStore } from '@/test/query-atom'
import PermissionSelector from '../index'
const mockConsoleState = vi.hoisted(() => ({
userProfile: {
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
const currentUser = {
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar: '',
avatar_url: null,
is_password_set: true,
timezone: 'UTC',
}
const memberList: Member[] = [
{
...currentUser,
avatar_url: '',
role: 'owner',
roles: [],
last_login_at: '',
created_at: '',
status: 'active',
},
}))
{
id: 'user-2',
name: 'John Doe',
email: 'john@example.com',
avatar: '',
avatar_url: '',
role: 'admin',
roles: [],
last_login_at: '',
created_at: '',
status: 'active',
},
{
id: 'user-3',
name: 'Jane Smith',
email: 'jane@example.com',
avatar: '',
avatar_url: '',
role: 'normal',
roles: [],
last_login_at: '',
created_at: '',
status: 'active',
},
]
let mockIsRbacEnabled = false
const defaultProps: ComponentProps<typeof PermissionSelector> = {
permission: DatasetPermission.onlyMe,
value: ['user-1'],
memberList,
onChange: vi.fn(),
onMemberSelect: vi.fn(),
}
vi.mock('@/context/account-state', async () => {
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
const renderSelector = (
props: Partial<ComponentProps<typeof PermissionSelector>> = {},
options: { rbacEnabled?: boolean } = {},
) => {
const { queryClient, store } = createQueryAtomTestStore()
queryClient.setQueryData(userProfileQueryOptions().queryKey, {
profile: currentUser,
meta: { currentVersion: null, currentEnv: null },
})
queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, {
...defaultSystemFeatures,
rbac_enabled: options.rbacEnabled ?? false,
})
const wrapper = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
)
return createAccountStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/system-features-state', async () => {
const { createSystemFeaturesStateModuleMock } = await import('@/test/console/state-fixture')
return createSystemFeaturesStateModuleMock(() => ({
...(() => mockConsoleState)(),
datasetRbacEnabled: (() => ({
isRbacEnabled: mockIsRbacEnabled,
}))().isRbacEnabled,
}))
})
return render(<PermissionSelector {...defaultProps} {...props} />, { wrapper })
}
describe('PermissionSelector', () => {
const mockMemberList: Member[] = [
{
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar: '',
avatar_url: '',
role: 'owner',
roles: [],
last_login_at: '',
created_at: '',
status: 'active',
}!,
{
id: 'user-2',
name: 'John Doe',
email: 'john@example.com',
avatar: '',
avatar_url: '',
role: 'admin',
roles: [],
last_login_at: '',
created_at: '',
status: 'active',
}!,
{
id: 'user-3',
name: 'Jane Smith',
email: 'jane@example.com',
avatar: '',
avatar_url: '',
role: 'editor',
roles: [],
last_login_at: '',
created_at: '',
status: 'active',
}!,
{
id: 'user-4',
name: 'Dataset Operator',
email: 'operator@example.com',
avatar: '',
avatar_url: '',
role: 'dataset_operator',
roles: [],
last_login_at: '',
created_at: '',
status: 'active',
}!,
]
const defaultProps = {
permission: DatasetPermission.onlyMe,
value: ['user-1'!],
memberList: mockMemberList,
onChange: vi.fn(),
onMemberSelect: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockIsRbacEnabled = false
})
describe('Rendering', () => {
it('should render Only Me option when permission is onlyMe', () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />,
)
expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument()
it('opens from its native button with the keyboard', async () => {
const user = userEvent.setup()
renderSelector()
const trigger = screen.getByRole('button', { name: /permissionsOnlyMe/ })
expect(trigger).toHaveAttribute('type', 'button')
await user.tab()
expect(trigger).toHaveFocus()
await user.keyboard('{Enter}')
const dialog = screen.getByRole('dialog', { name: /form.permissions/ })
expect(within(dialog).getByRole('radiogroup', { name: /form.permissions/ })).toBeInTheDocument()
expect(within(dialog).getByRole('radio', { name: /permissionsOnlyMe/ })).toBeChecked()
})
it.each([
['the disabled prop', { disabled: true }, false],
['dataset RBAC', {}, true],
])('uses a disabled native trigger for %s', async (_, props, rbacEnabled) => {
const user = userEvent.setup()
renderSelector(props, { rbacEnabled })
const trigger = rbacEnabled
? screen.getByRole('button', { name: /permissionsAccessConfig/ })
: screen.getByRole('button', { name: /permissionsOnlyMe/ })
expect(trigger).toBeDisabled()
await user.click(trigger)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it.each([
[DatasetPermission.onlyMe, /permissionsOnlyMe/],
[DatasetPermission.allTeamMembers, /permissionsAllMember/],
])('selects %s and closes the popover', async (permission, optionName) => {
const user = userEvent.setup()
const onChange = vi.fn()
renderSelector({
permission:
permission === DatasetPermission.onlyMe
? DatasetPermission.allTeamMembers
: DatasetPermission.onlyMe,
onChange,
})
it('should render All Team Members option when permission is allTeamMembers', () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />,
)
expect(screen.getByText(/form\.permissionsAllMember/))!.toBeInTheDocument()
await user.click(
screen.getByRole('button', {
name:
permission === DatasetPermission.onlyMe ? /permissionsAllMember/ : /permissionsOnlyMe/,
}),
)
const popover = screen.getByRole('dialog', { name: /form.permissions/ })
await user.click(within(popover).getByRole('radio', { name: optionName }))
expect(onChange).toHaveBeenCalledWith(permission)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('resets partial access to the current user and keeps the popover open', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onMemberSelect = vi.fn()
renderSelector({ onChange, onMemberSelect })
await user.click(screen.getByRole('button', { name: /permissionsOnlyMe/ }))
const popover = screen.getByRole('dialog', { name: /form.permissions/ })
await user.click(within(popover).getByRole('radio', { name: /permissionsInvitedMembers/ }))
expect(onChange).toHaveBeenCalledWith(DatasetPermission.partialMembers)
expect(onMemberSelect).toHaveBeenCalledWith(['user-1'])
expect(screen.getByRole('dialog', { name: /form.permissions/ })).toBeInTheDocument()
})
it('uses radio keyboard navigation without closing the popover', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderSelector({ onChange })
await user.click(screen.getByRole('button', { name: /permissionsOnlyMe/ }))
const dialog = screen.getByRole('dialog', { name: /form.permissions/ })
const onlyMe = within(dialog).getByRole('radio', { name: /permissionsOnlyMe/ })
onlyMe.focus()
await user.keyboard('{ArrowDown}')
expect(onChange).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
expect(dialog).toBeInTheDocument()
})
it.each([
[['user-1'], ['user-1', 'user-2']],
[['user-1', 'user-2'], ['user-1']],
])('toggles a member using a native button', async (value, expectedValue) => {
const user = userEvent.setup()
const onMemberSelect = vi.fn()
renderSelector({
permission: DatasetPermission.partialMembers,
value,
onMemberSelect,
})
it('should render selected member names when permission is partialMembers', () => {
renderWithConsoleQuery(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
/>,
)
// Should show member names
// Should show member names
expect(screen.getByTitle(/Current User/))!.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Current User/ }))
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: /John Doe/ }))
expect(onMemberSelect).toHaveBeenCalledWith(expectedValue)
})
it('filters members after the search debounce and clears the query', async () => {
const user = userEvent.setup()
renderSelector({ permission: DatasetPermission.partialMembers })
await user.click(screen.getByRole('button', { name: /Current User/ }))
const search = screen.getByRole('textbox', { name: /operation.search/ })
await user.type(search, 'Jane')
await waitFor(() => {
expect(screen.getByRole('button', { name: /Jane Smith/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /John Doe/ })).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /operation.clear/ }))
expect(search).toHaveValue('')
await waitFor(() => {
expect(screen.getByRole('button', { name: /John Doe/ })).toBeInTheDocument()
})
})
describe('Dropdown Toggle', () => {
it('should open dropdown when clicked', async () => {
renderWithConsoleQuery(<PermissionSelector {...defaultProps} />)
it('shows the empty state when no member matches', async () => {
const user = userEvent.setup()
renderSelector({ permission: DatasetPermission.partialMembers })
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
await user.click(screen.getByRole('button', { name: /Current User/ }))
await user.type(screen.getByRole('textbox', { name: /operation.search/ }), 'Nobody')
await waitFor(() => {
// Should show all permission options in dropdown
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBeGreaterThanOrEqual(1)
})
})
it('should not open dropdown when disabled', () => {
renderWithConsoleQuery(<PermissionSelector {...defaultProps} disabled={true} />)
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
// Dropdown should not open - only the trigger text should be visible
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBe(1)
})
})
describe('Permission Selection', () => {
it('should call onChange with onlyMe when Only Me is selected', async () => {
const handleChange = vi.fn()
renderWithConsoleQuery(
<PermissionSelector
{...defaultProps}
onChange={handleChange}
permission={DatasetPermission.allTeamMembers}
/>,
)
const trigger = screen.getByText(/form\.permissionsAllMember/)
fireEvent.click(trigger)
await waitFor(() => {
const onlyMeOptions = screen.getAllByText(/form\.permissionsOnlyMe/)
fireEvent.click(onlyMeOptions[0]!)
})
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.onlyMe)
})
it('should call onChange with allTeamMembers when All Team Members is selected', async () => {
const handleChange = vi.fn()
renderWithConsoleQuery(<PermissionSelector {...defaultProps} onChange={handleChange} />)
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
await waitFor(() => {
const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/)
fireEvent.click(allMemberOptions[0]!)
})
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
})
it('should call onChange with partialMembers when Invited Members is selected', async () => {
const handleChange = vi.fn()
const handleMemberSelect = vi.fn()
renderWithConsoleQuery(
<PermissionSelector
{...defaultProps}
onChange={handleChange}
onMemberSelect={handleMemberSelect}
/>,
)
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
fireEvent.click(trigger)
await waitFor(() => {
const invitedOptions = screen.getAllByText(/form\.permissionsInvitedMembers/)
fireEvent.click(invitedOptions[0]!)
})
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.partialMembers)
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
})
})
describe('Member Selection', () => {
it('should show member list when partialMembers is selected', async () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
await waitFor(() => {
// Should show member list
// Should show member list
expect(screen.getByText('John Doe'))!.toBeInTheDocument()
expect(screen.getByText('Jane Smith'))!.toBeInTheDocument()
})
})
it('should call onMemberSelect when a member is clicked', async () => {
const handleMemberSelect = vi.fn()
renderWithConsoleQuery(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
onMemberSelect={handleMemberSelect}
/>,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
await waitFor(() => {
const johnDoe = screen.getByText('John Doe')
fireEvent.click(johnDoe)
})
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1', 'user-2'])
})
it('should deselect member when clicked again', async () => {
const handleMemberSelect = vi.fn()
renderWithConsoleQuery(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
onMemberSelect={handleMemberSelect}
/>,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
await waitFor(() => {
const johnDoe = screen.getByText('John Doe')
fireEvent.click(johnDoe)
})
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
})
})
describe('Search Functionality', () => {
it('should allow typing in search input', async () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Type in search
fireEvent.change(searchInput, { target: { value: 'John' } })
expect(searchInput)!.toHaveValue('John')
})
it('should render search input in partial members mode', async () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open and search input to be available
const searchInput = await screen.findByRole('textbox')
expect(searchInput)!.toBeInTheDocument()
})
it('should filter members after debounce completes', async () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Type in search
fireEvent.change(searchInput, { target: { value: 'John' } })
// Wait for debounce (500ms) + buffer
await waitFor(
() => {
expect(screen.getByText('John Doe'))!.toBeInTheDocument()
},
{ timeout: 1000 },
)
})
it('should handle clear search functionality', async () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Type in search
fireEvent.change(searchInput, { target: { value: 'test' } })
expect(searchInput)!.toHaveValue('test')
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
fireEvent.click(clearButton)
// After clicking clear, input should be empty
await waitFor(() => {
expect(searchInput)!.toHaveValue('')
})
})
it('should filter members by email', async () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search by email
fireEvent.change(searchInput, { target: { value: 'john@example' } })
// Wait for debounce
await waitFor(
() => {
expect(screen.getByText('John Doe'))!.toBeInTheDocument()
},
{ timeout: 1000 },
)
})
it('should show no results message when search matches nothing', async () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search for non-existent member
fireEvent.change(searchInput, { target: { value: 'nonexistent12345' } })
// Wait for debounce and no results message
await waitFor(
() => {
expect(screen.getByText(/form\.onSearchResults/))!.toBeInTheDocument()
},
{ timeout: 1000 },
)
})
it('should show current user when search matches user name', async () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search for current user by name - partial match
fireEvent.change(searchInput, { target: { value: 'Current' } })
// Current user (showMe) should remain visible based on name match
// The component uses useMemo to check if userProfile.name.includes(searchKeywords)
// Current user (showMe) should remain visible based on name match
// The component uses useMemo to check if userProfile.name.includes(searchKeywords)
expect(searchInput)!.toHaveValue('Current')
// Current User label appears multiple times (trigger + member list)
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
})
it('should show current user when search matches user email', async () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
)
const trigger = screen.getByTitle(/Current User/)
fireEvent.click(trigger)
// Wait for dropdown to open
const searchInput = await screen.findByRole('textbox')
// Search for current user by email
fireEvent.change(searchInput, { target: { value: 'current@' } })
// The component checks userProfile.email.includes(searchKeywords)
// The component checks userProfile.email.includes(searchKeywords)
expect(searchInput)!.toHaveValue('current@')
// Current User should remain visible based on email match
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
})
})
describe('Disabled State', () => {
it('should apply disabled styles when disabled', () => {
const { container } = renderWithConsoleQuery(
<PermissionSelector {...defaultProps} disabled={true} />,
)
// When disabled, the component has cursor-not-allowed! class (escaped in Tailwind)
const triggerElement = container.querySelector('[class*="cursor-not-allowed"]')
expect(triggerElement)!.toBeInTheDocument()
})
it('should show access config hint and remain closed when RBAC is enabled', () => {
mockIsRbacEnabled = true
renderWithConsoleQuery(<PermissionSelector {...defaultProps} />, {
systemFeatures: {
rbac_enabled: true,
},
})
const trigger = screen.getByText(/form\.permissionsAccessConfig/)
fireEvent.click(trigger)
expect(screen.getByText(/form\.permissionsAccessConfig/))!.toBeInTheDocument()
expect(screen.queryByText(/form\.permissionsOnlyMe/))!.not.toBeInTheDocument()
})
})
describe('Display Variations', () => {
it('should display single avatar when only one member selected', () => {
renderWithConsoleQuery(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1']}
/>,
)
// Should display single avatar
// Should display single avatar
expect(screen.getByTitle(/Current User/))!.toBeInTheDocument()
})
it('should display two avatars when two or more members selected', () => {
renderWithConsoleQuery(
<PermissionSelector
{...defaultProps}
permission={DatasetPermission.partialMembers}
value={['user-1', 'user-2']}
/>,
)
// Should display member names
// Should display member names
expect(screen.getByTitle(/Current User, John Doe/))!.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty member list', () => {
renderWithConsoleQuery(<PermissionSelector {...defaultProps} memberList={[]} />)
expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument()
})
it('should handle member list with only current user', () => {
renderWithConsoleQuery(
<PermissionSelector {...defaultProps} memberList={[mockMemberList[0]!]} />,
)
expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument()
})
it('should only show members with allowed roles', () => {
// The component filters members by role in useMemo
// Allowed roles are: owner, admin, editor, dataset_operator
// This is tested indirectly through the memberList filtering
const memberListWithNormalUser: Member[] = [
...mockMemberList,
{
id: 'user-5',
name: 'Normal User',
email: 'normal@example.com',
avatar: '',
avatar_url: '',
role: 'normal',
roles: [],
last_login_at: '',
created_at: '',
status: 'active',
},
]
renderWithConsoleQuery(
<PermissionSelector
{...defaultProps}
memberList={memberListWithNormalUser}
permission={DatasetPermission.partialMembers}
/>,
)
// The component renders - the filtering logic is internal
// The component renders - the filtering logic is internal
expect(screen.getByTitle(/Current User/))!.toBeInTheDocument()
})
})
describe('Props', () => {
it('should update when permission prop changes', () => {
const { rerender } = renderWithConsoleQuery(
<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />,
)
expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument()
rerender(
<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />,
)
expect(screen.getByText(/form\.permissionsAllMember/))!.toBeInTheDocument()
})
expect(await screen.findByText(/form.onSearchResults/)).toBeInTheDocument()
})
})

View File

@ -2,24 +2,25 @@ import type { Member } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RadioGroup } from '@langgenius/dify-ui/radio'
import { useDebounceFn } from 'ahooks'
import { useAtomValue } from 'jotai'
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { userProfileAtom } from '@/context/account-state'
import { datasetRbacEnabledAtom } from '@/context/system-features-state'
import { DatasetPermission } from '@/models/datasets'
import MemberItem from './member-item'
import Item from './permission-item'
import PermissionItem from './permission-item'
type RoleSelectorProps = {
type PermissionSelectorProps = {
disabled?: boolean
permission?: DatasetPermission
value: string[]
memberList: Member[]
onChange: (permission?: DatasetPermission) => void
onMemberSelect: (v: string[]) => void
onMemberSelect: (value: string[]) => void
}
const PermissionSelector = ({
@ -29,283 +30,272 @@ const PermissionSelector = ({
memberList,
onChange,
onMemberSelect,
}: RoleSelectorProps) => {
}: PermissionSelectorProps) => {
const { t } = useTranslation()
const userProfile = useAtomValue(userProfileAtom)
const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom)
const [open, setOpen] = useState(false)
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(
() => {
setSearchKeywords(keywords)
(nextKeywords: string) => {
setSearchKeywords(nextKeywords)
},
{ wait: 500 },
)
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
const handleKeywordsChange = (nextKeywords: string) => {
setKeywords(nextKeywords)
handleSearch(nextKeywords)
}
const selectMember = (member: Member) => {
if (value.includes(member.id)) onMemberSelect(value.filter((id) => id !== member.id))
else onMemberSelect([...value, member.id])
}
const selectMember = useCallback(
(member: Member) => {
if (value.includes(member.id)) onMemberSelect(value.filter((v) => v !== member.id))
else onMemberSelect([...value, member.id])
},
[value, onMemberSelect],
)
const selectedMembers = useMemo(() => {
return [
const selectedMembers = useMemo(
() => [
userProfile,
...memberList
.filter((member) => member.id !== userProfile.id)
.filter((member) => value.includes(member.id)),
]
}, [userProfile, value, memberList])
const showMe = useMemo(() => {
return userProfile.name.includes(searchKeywords) || userProfile.email.includes(searchKeywords)
}, [searchKeywords, userProfile])
const filteredMemberList = useMemo(() => {
return memberList.filter(
(member) =>
(member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) &&
member.id !== userProfile.id,
)
}, [memberList, searchKeywords, userProfile])
const onSelectOnlyMe = useCallback(() => {
onChange(DatasetPermission.onlyMe)
setOpen(false)
}, [onChange])
const onSelectAllMembers = useCallback(() => {
onChange(DatasetPermission.allTeamMembers)
setOpen(false)
}, [onChange])
const onSelectPartialMembers = useCallback(() => {
onChange(DatasetPermission.partialMembers)
onMemberSelect([userProfile.id])
}, [onChange, onMemberSelect, userProfile])
...memberList.filter((member) => member.id !== userProfile.id && value.includes(member.id)),
],
[memberList, userProfile, value],
)
const filteredMemberList = useMemo(
() =>
memberList.filter(
(member) =>
member.id !== userProfile.id &&
(member.name.includes(searchKeywords) || member.email.includes(searchKeywords)),
),
[memberList, searchKeywords, userProfile.id],
)
const isOnlyMe = permission === DatasetPermission.onlyMe
const isAllTeamMembers = permission === DatasetPermission.allTeamMembers
const isPartialMembers = permission === DatasetPermission.partialMembers
const showMe =
userProfile.name.includes(searchKeywords) || userProfile.email.includes(searchKeywords)
const selectedMemberNames = selectedMembers.map((member) => member.name).join(', ')
const isDisabledByRBAC = isRbacEnabled
const isDisabled = disabled || isDisabledByRBAC
const isDisabledByRbac = isRbacEnabled
const isDisabled = disabled || isDisabledByRbac
const permissionLabel = t(($) => $['form.permissions'], { ns: 'datasetSettings' })
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (isDisabled) return
setOpen(nextOpen)
}}
>
<div className="relative">
<PopoverTrigger
render={
<div
className={cn(
'group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
isDisabled &&
'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!',
)}
>
{isDisabledByRBAC && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<span className="i-ri-lock-2-line size-4 text-text-tertiary" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">
{t(($) => $['form.permissionsAccessConfig'], { ns: 'datasetSettings' })}
</div>
</>
)}
{!isDisabledByRBAC && isOnlyMe && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
</div>
</>
)}
{!isDisabledByRBAC && isAllTeamMembers && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<span className="i-ri-group-2-line size-4 text-text-secondary" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
</div>
</>
)}
{!isDisabledByRBAC && isPartialMembers && (
<>
<div className="relative flex size-6 shrink-0 items-center justify-center">
{selectedMembers.length === 1 && (
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
size="xs"
/>
)}
{selectedMembers.length >= 2 && (
<>
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
className="absolute top-0 left-0 z-0"
size="xxs"
/>
<Avatar
avatar={selectedMembers[1]!.avatar_url}
name={selectedMembers[1]!.name}
className="absolute right-0 bottom-0 z-10"
size="xxs"
/>
</>
)}
</div>
<div
title={selectedMemberNames}
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
>
{selectedMemberNames}
</div>
</>
)}
<span
className={cn(
'i-ri-arrow-down-s-line',
'size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary group-data-popup-open:text-text-secondary',
isDisabled && 'text-components-input-text-placeholder!',
)}
/>
<Popover>
<PopoverTrigger
disabled={isDisabled}
className={cn(
'group/permission-trigger flex w-full cursor-pointer touch-manipulation items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt',
'data-disabled:cursor-not-allowed! data-disabled:bg-components-input-bg-disabled! data-disabled:hover:bg-components-input-bg-disabled!',
)}
>
{isDisabledByRbac && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<span aria-hidden="true" className="i-ri-lock-2-line size-4 text-text-tertiary" />
</div>
}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
<div className="p-1">
{/* Only me */}
<Item
leftIcon={
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">
{t(($) => $['form.permissionsAccessConfig'], { ns: 'datasetSettings' })}
</div>
</>
)}
{!isDisabledByRbac && isOnlyMe && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
</div>
</>
)}
{!isDisabledByRbac && isAllTeamMembers && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<span aria-hidden="true" className="i-ri-group-2-line size-4 text-text-secondary" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
</div>
</>
)}
{!isDisabledByRbac && isPartialMembers && (
<>
<div className="relative flex size-6 shrink-0 items-center justify-center">
{selectedMembers.length === 1 && (
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
size="xs"
/>
)}
{selectedMembers.length >= 2 && (
<>
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
className="shrink-0"
size="sm"
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
className="absolute top-0 left-0 z-0"
size="xxs"
/>
}
text={t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
onClick={onSelectOnlyMe}
isSelected={isOnlyMe}
/>
{/* All team members */}
<Item
leftIcon={
<div className="flex size-6 shrink-0 items-center justify-center">
<span className="i-ri-group-2-line size-4 text-text-secondary" />
</div>
}
text={t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
onClick={onSelectAllMembers}
isSelected={isAllTeamMembers}
/>
{/* Partial members */}
<Item
leftIcon={
<div className="flex size-6 shrink-0 items-center justify-center">
<span className="i-ri-lock-2-line size-4 text-text-secondary" />
</div>
}
text={t(($) => $['form.permissionsInvitedMembers'], { ns: 'datasetSettings' })}
onClick={onSelectPartialMembers}
isSelected={isPartialMembers}
/>
<Avatar
avatar={selectedMembers[1]!.avatar_url}
name={selectedMembers[1]!.name}
className="absolute right-0 bottom-0 z-10"
size="xxs"
/>
</>
)}
</div>
{isPartialMembers && (
<div className="max-h-[360px] overflow-y-auto border-t border-divider-regular pr-1 pb-1 pl-1">
<div className="sticky top-0 left-0 z-10 bg-components-panel-on-panel-item-bg p-2 pb-1">
<div className="relative w-full">
<span className="absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
<Input
className={cn('w-full pl-[26px]', keywords && 'pr-[26px]')}
value={keywords}
placeholder={t(($) => $['operation.search'], { ns: 'common' }) || ''}
onChange={(e) => handleKeywordsChange(e.target.value)}
/>
{!!keywords && (
<button
type="button"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className="group absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer border-none bg-transparent p-px"
onClick={() => handleKeywordsChange('')}
>
<span
className="i-ri-close-circle-fill size-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary"
aria-hidden="true"
/>
</button>
)}
</div>
<div
title={selectedMemberNames}
className="min-w-0 grow truncate p-1 system-sm-regular text-components-input-text-filled"
>
{selectedMemberNames}
</div>
</>
)}
<span
aria-hidden="true"
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary',
'group-hover/permission-trigger:text-text-secondary group-data-popup-open/permission-trigger:text-text-secondary',
'group-data-disabled/permission-trigger:text-components-input-text-placeholder!',
)}
/>
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-none bg-transparent shadow-none"
>
<PopoverTitle className="sr-only">{permissionLabel}</PopoverTitle>
<div className="relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
<RadioGroup<DatasetPermission>
value={permission}
onValueChange={(nextPermission) => {
onChange(nextPermission)
if (nextPermission === DatasetPermission.partialMembers)
onMemberSelect([userProfile.id])
}}
aria-label={permissionLabel}
className="flex-col items-stretch gap-0 p-1"
>
<PermissionItem
value={DatasetPermission.onlyMe}
closeOnSelect
leftIcon={
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
className="shrink-0"
size="sm"
/>
}
text={t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
isSelected={isOnlyMe}
/>
<PermissionItem
value={DatasetPermission.allTeamMembers}
closeOnSelect
leftIcon={
<div className="flex size-6 shrink-0 items-center justify-center">
<span
aria-hidden="true"
className="i-ri-group-2-line size-4 text-text-secondary"
/>
</div>
<div className="flex flex-col p-1">
{showMe && (
<MemberItem
leftIcon={
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
className="shrink-0"
size="sm"
/>
}
name={userProfile.name}
email={userProfile.email}
isSelected
isMe
/>
)}
{filteredMemberList.map((member) => (
<MemberItem
key={member.id}
leftIcon={
<Avatar
avatar={member.avatar_url}
name={member.name}
className="shrink-0"
size="sm"
/>
}
name={member.name}
email={member.email}
isSelected={value.includes(member.id)}
onClick={selectMember.bind(null, member)}
/>
))}
{!showMe && filteredMemberList.length === 0 && (
<div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary">
{t(($) => $['form.onSearchResults'], { ns: 'datasetSettings' })}
</div>
}
text={t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
isSelected={isAllTeamMembers}
/>
<PermissionItem
value={DatasetPermission.partialMembers}
leftIcon={
<div className="flex size-6 shrink-0 items-center justify-center">
<span
aria-hidden="true"
className="i-ri-lock-2-line size-4 text-text-secondary"
/>
</div>
}
text={t(($) => $['form.permissionsInvitedMembers'], { ns: 'datasetSettings' })}
isSelected={isPartialMembers}
/>
</RadioGroup>
{isPartialMembers && (
<div className="max-h-[360px] overflow-y-auto border-t border-divider-regular pr-1 pb-1 pl-1">
<div className="sticky top-0 left-0 z-10 bg-components-panel-on-panel-item-bg p-2 pb-1">
<div className="relative w-full">
<span
aria-hidden="true"
className="absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder"
/>
<Input
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
name="member-search"
autoComplete="off"
className={cn('w-full pl-[26px]', keywords && 'pr-[26px]')}
value={keywords}
placeholder={t(($) => $['operation.search'], { ns: 'common' }) || ''}
onChange={(event) => handleKeywordsChange(event.target.value)}
/>
{!!keywords && (
<button
type="button"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className="group absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer touch-manipulation border-none bg-transparent p-px outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => handleKeywordsChange('')}
>
<span
aria-hidden="true"
className="i-ri-close-circle-fill size-3.5 text-text-quaternary group-hover:text-text-tertiary"
/>
</button>
)}
</div>
</div>
)}
</div>
</PopoverContent>
</div>
<div className="flex flex-col p-1">
{showMe && (
<MemberItem
leftIcon={
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
className="shrink-0"
size="sm"
/>
}
name={userProfile.name}
email={userProfile.email}
isSelected
isMe
/>
)}
{filteredMemberList.map((member) => (
<MemberItem
key={member.id}
leftIcon={
<Avatar
avatar={member.avatar_url}
name={member.name}
className="shrink-0"
size="sm"
/>
}
name={member.name}
email={member.email}
isSelected={value.includes(member.id)}
onClick={() => selectMember(member)}
/>
))}
{!showMe && filteredMemberList.length === 0 && (
<div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary">
{t(($) => $['form.onSearchResults'], { ns: 'datasetSettings' })}
</div>
)}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -1,10 +1,9 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type MemberItemProps = {
leftIcon: React.ReactNode
leftIcon: ReactNode
name: string
email: string
isSelected: boolean
@ -22,13 +21,10 @@ const MemberItem = ({
}: MemberItemProps) => {
const { t } = useTranslation()
return (
<div
className="flex cursor-pointer items-center gap-2 rounded-lg py-1 pr-[10px] pl-2 hover:bg-state-base-hover"
onClick={onClick}
>
const content = (
<>
{leftIcon}
<div className="grow">
<div className="min-w-0 grow">
<div className="truncate system-sm-medium text-text-secondary">
{name}
{isMe && (
@ -40,10 +36,28 @@ const MemberItem = ({
<div className="truncate system-xs-regular text-text-tertiary">{email}</div>
</div>
{isSelected && (
<RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} />
<span
aria-hidden="true"
className={cn('i-ri-check-line size-4 shrink-0 text-text-accent', isMe && 'opacity-30')}
/>
)}
</div>
</>
)
if (isMe) {
return <div className="flex items-center gap-2 rounded-lg py-1 pr-[10px] pl-2">{content}</div>
}
return (
<button
type="button"
className="flex w-full cursor-pointer touch-manipulation items-center gap-2 rounded-lg border-none bg-transparent py-1 pr-[10px] pl-2 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
aria-pressed={isSelected}
onClick={onClick}
>
{content}
</button>
)
}
export default React.memo(MemberItem)
export default MemberItem

View File

@ -1,24 +1,49 @@
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
import type { ReactNode } from 'react'
import type { DatasetPermission } from '@/models/datasets'
import { PopoverClose } from '@langgenius/dify-ui/popover'
import { RadioItem } from '@langgenius/dify-ui/radio'
type PermissionItemProps = {
leftIcon: React.ReactNode
value: DatasetPermission
leftIcon: ReactNode
text: string
onClick: () => void
isSelected: boolean
closeOnSelect?: boolean
}
const PermissionItem = ({ leftIcon, text, onClick, isSelected }: PermissionItemProps) => {
return (
<div
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover"
onClick={onClick}
>
const className =
'flex w-full touch-manipulation cursor-pointer items-center gap-x-1 rounded-lg border-none bg-transparent px-2 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const PermissionItem = ({
value,
leftIcon,
text,
isSelected,
closeOnSelect = false,
}: PermissionItemProps) => {
const content = (
<>
{leftIcon}
<div className="grow px-1 system-md-regular text-text-secondary">{text}</div>
{isSelected && <RiCheckLine className="size-4 text-text-accent" />}
</div>
{isSelected && (
<span aria-hidden="true" className="i-ri-check-line size-4 text-text-accent" />
)}
</>
)
if (closeOnSelect) {
return (
<PopoverClose render={<RadioItem<DatasetPermission> value={value} />} className={className}>
{content}
</PopoverClose>
)
}
return (
<RadioItem<DatasetPermission> value={value} className={className}>
{content}
</RadioItem>
)
}
export default React.memo(PermissionItem)
export default PermissionItem

View File

@ -2,7 +2,7 @@ import type { ApiKeyModalProps } from '../api-key-modal'
import type { FormSchema } from '@/app/components/base/form/types'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -97,10 +97,6 @@ vi.mock('@/app/components/base/form/types', () => ({
FormTypeEnum: { textInput: 'text-input' },
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({ data: { accounts: [] } }),
}))
const basePayload = {
category: AuthCategory.tool,
provider: 'test-provider',
@ -298,11 +294,50 @@ describe('ApiKeyModal', () => {
expect.objectContaining({
type: 'api-key',
name: 'My Key',
visibility: 'all_team_members',
}),
)
})
})
it('selects credential visibility through the native trigger', async () => {
const user = userEvent.setup()
render(<ApiKeyModal pluginPayload={basePayload} />)
const trigger = screen.getByRole('button', { name: /permissionsAllMember/ })
expect(trigger).toHaveAttribute('type', 'button')
await user.click(trigger)
const permissionDialog = screen.getByRole('dialog', { name: /auth.whoCanUse/ })
const permissionGroup = within(permissionDialog).getByRole('radiogroup', {
name: /auth.whoCanUse/,
})
const allMembers = within(permissionGroup).getByRole('radio', {
name: /permissionsAllMember/,
})
const onlyMe = within(permissionGroup).getByRole('radio', { name: /permissionsOnlyMe/ })
expect(allMembers).toBeChecked()
allMembers.focus()
await user.keyboard('{ArrowUp}')
expect(onlyMe).toBeChecked()
expect(permissionDialog).toBeInTheDocument()
await user.click(onlyMe)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockAddPluginCredential).toHaveBeenCalledWith(
expect.objectContaining({ visibility: 'only_me' }),
)
})
})
it('disables the credential visibility trigger with the modal', () => {
render(<ApiKeyModal pluginPayload={basePayload} disabled />)
expect(screen.getByRole('button', { name: /permissionsAllMember/ })).toBeDisabled()
})
it('should use empty credential name when authorization name is blank in add mode', async () => {
mockFormValues = { isCheckValidated: true, values: { api_key: 'sk-123' } }

View File

@ -1,4 +1,5 @@
import type { PluginPayload } from '../types'
import type { CredentialPermission } from './permission-selector'
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
@ -9,9 +10,7 @@ import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Loading from '@/app/components/base/loading'
import PermissionSelector from '@/app/components/base/permission-selector'
import { PermissionLevel } from '@/models/permission'
import { useMembers } from '@/service/use-common'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import {
useAddPluginCredentialHook,
@ -19,6 +18,7 @@ import {
useUpdatePluginCredentialHook,
} from '../hooks/use-credential'
import { CredentialTypeEnum } from '../types'
import PermissionSelector from './permission-selector'
export type ApiKeyModalProps = {
pluginPayload: PluginPayload
@ -53,15 +53,11 @@ const ApiKeyModal = ({
pluginPayload,
CredentialTypeEnum.API_KEY,
)
const [permission, setPermission] = useState<PermissionLevel | undefined>(
(editValues?.__visibility__ as PermissionLevel) ?? PermissionLevel.allTeamMembers,
const [permission, setPermission] = useState<CredentialPermission>(
editValues?.__visibility__ === PermissionLevel.onlyMe
? PermissionLevel.onlyMe
: PermissionLevel.allTeamMembers,
)
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(
(editValues?.__partial_member_list__ as string[]) ?? [],
)
// Only need member list when creating (the permission selector is hidden on edit).
const { data: membersData } = useMembers()
const memberList = membersData?.accounts ?? []
const mergedData = useMemo(() => {
if (formSchemasFromProps?.length) return formSchemasFromProps
@ -115,17 +111,11 @@ const ApiKeyModal = ({
name: __name__ || '',
})
} else {
const permissionPayload = {
visibility: permission,
...(permission === PermissionLevel.partialMembers
? { partial_member_list: selectedMemberIDs.map((id) => ({ user_id: id })) }
: {}),
}
await addPluginCredential({
credentials: restValues,
type: CredentialTypeEnum.API_KEY,
name: __name__ || '',
...permissionPayload,
visibility: permission,
})
}
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }))
@ -146,7 +136,6 @@ const ApiKeyModal = ({
editValues,
handleSetDoingAction,
permission,
selectedMemberIDs,
])
const isDisabled = disabled || isLoading || doingAction
@ -199,11 +188,7 @@ const ApiKeyModal = ({
<PermissionSelector
disabled={disabled}
permission={permission}
value={selectedMemberIDs}
memberList={memberList}
onChange={(v) => setPermission(v)}
onMemberSelect={setSelectedMemberIDs}
hidePartialMembers
onChange={setPermission}
/>
</div>
)}

View File

@ -0,0 +1,115 @@
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { userProfileAtom } from '@/context/account-state'
import { PermissionLevel } from '@/models/permission'
export type CredentialPermission =
| typeof PermissionLevel.onlyMe
| typeof PermissionLevel.allTeamMembers
type PermissionSelectorProps = {
disabled?: boolean
permission: CredentialPermission
onChange: (permission: CredentialPermission) => void
}
const optionClassName =
'flex w-full touch-manipulation cursor-pointer items-center gap-x-1 rounded-lg border-none bg-transparent px-2 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const PermissionSelector = ({ disabled, permission, onChange }: PermissionSelectorProps) => {
const { t } = useTranslation()
const userProfile = useAtomValue(userProfileAtom)
const isOnlyMe = permission === PermissionLevel.onlyMe
const isAllTeamMembers = permission === PermissionLevel.allTeamMembers
const permissionLabel = t(($) => $['auth.whoCanUse'], { ns: 'plugin' })
return (
<Popover>
<PopoverTrigger
disabled={disabled}
className={cn(
'group/permission-trigger flex w-full cursor-pointer touch-manipulation items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt',
'data-disabled:cursor-not-allowed! data-disabled:bg-components-input-bg-disabled! data-disabled:hover:bg-components-input-bg-disabled!',
)}
>
{isOnlyMe && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
</div>
</>
)}
{isAllTeamMembers && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<span aria-hidden="true" className="i-ri-group-2-line size-4 text-text-secondary" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
</div>
</>
)}
<span
aria-hidden="true"
className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-data-disabled/permission-trigger:text-components-input-text-placeholder! group-data-popup-open/permission-trigger:text-text-secondary"
/>
</PopoverTrigger>
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[480px] p-0">
<PopoverTitle className="sr-only">{permissionLabel}</PopoverTitle>
<RadioGroup<CredentialPermission>
value={permission}
onValueChange={onChange}
aria-label={permissionLabel}
className="flex-col items-stretch gap-0 p-1"
>
<PopoverClose
render={<RadioItem<CredentialPermission> value={PermissionLevel.onlyMe} />}
className={optionClassName}
>
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
className="shrink-0"
size="sm"
/>
<div className="grow px-1 system-md-regular text-text-secondary">
{t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
</div>
{isOnlyMe && (
<span aria-hidden="true" className="i-ri-check-line size-4 text-text-accent" />
)}
</PopoverClose>
<PopoverClose
render={<RadioItem<CredentialPermission> value={PermissionLevel.allTeamMembers} />}
className={optionClassName}
>
<div className="flex size-6 shrink-0 items-center justify-center">
<span aria-hidden="true" className="i-ri-group-2-line size-4 text-text-secondary" />
</div>
<div className="grow px-1 system-md-regular text-text-secondary">
{t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
</div>
{isAllTeamMembers && (
<span aria-hidden="true" className="i-ri-check-line size-4 text-text-accent" />
)}
</PopoverClose>
</RadioGroup>
</PopoverContent>
</Popover>
)
}
export default PermissionSelector