mirror of
https://github.com/langgenius/dify.git
synced 2026-05-31 14:16:23 +08:00
Unify deployment access permission copy
This commit is contained in:
@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SpecificGroupsOrMembersProps } from './specific-groups-or-members'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||
|
||||
type AccessControlDialogContentProps = {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
accessLabel?: ReactNode
|
||||
hideExternal?: boolean
|
||||
hideExternalTip?: boolean
|
||||
saving?: boolean
|
||||
confirmDisabled?: boolean
|
||||
specificGroupsOrMembersProps?: SpecificGroupsOrMembersProps
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export default function AccessControlDialogContent({
|
||||
title,
|
||||
description,
|
||||
accessLabel,
|
||||
hideExternal = false,
|
||||
hideExternalTip = false,
|
||||
saving = false,
|
||||
confirmDisabled = false,
|
||||
specificGroupsOrMembersProps,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: AccessControlDialogContentProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{title ?? t('accessControlDialog.title', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{description ?? t('accessControlDialog.description', { ns: 'app' })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 px-6 pb-3">
|
||||
<div className="leading-6">
|
||||
<p className="system-sm-medium text-text-tertiary">
|
||||
{accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">
|
||||
{t('accessControlDialog.accessItems.organization', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<SpecificGroupsOrMembers {...specificGroupsOrMembersProps} />
|
||||
</AccessControlItem>
|
||||
{!hideExternal && (
|
||||
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-verified-badge-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">
|
||||
{t('accessControlDialog.accessItems.external', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
{!hideExternalTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
)}
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<div className="flex items-center gap-x-2 p-3">
|
||||
<span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">
|
||||
{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
|
||||
<Button disabled={saving} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button disabled={confirmDisabled || saving} loading={saving} variant="primary" onClick={onConfirm}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,34 +1,25 @@
|
||||
'use client'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import AccessControlOptionCard from '@/app/components/base/access-control-option-card'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
|
||||
type AccessControlItemProps = PropsWithChildren<{
|
||||
type: AccessMode
|
||||
}>
|
||||
|
||||
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
|
||||
function AccessControlItem({ type, children }: AccessControlItemProps) {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
if (currentMenu !== type) {
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer rounded-[10px] border
|
||||
border-components-option-card-option-border bg-components-option-card-option-bg
|
||||
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
|
||||
onClick={() => setCurrentMenu(type)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const selected = currentMenu === type
|
||||
|
||||
return (
|
||||
<div className="rounded-[10px] border-[1.5px]
|
||||
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm"
|
||||
<AccessControlOptionCard
|
||||
selected={selected}
|
||||
onSelect={selected ? undefined : () => setCurrentMenu(type)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccessControlOptionCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,369 +1,28 @@
|
||||
'use client'
|
||||
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
AccessSubjectAddButton,
|
||||
} from '@/app/components/base/access-subject-selector'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import Loading from '../../base/loading'
|
||||
|
||||
export default function AddMemberOrGroupDialog() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
|
||||
const pages = data?.pages ?? []
|
||||
const subjects = pages.flatMap(page => page.subjects ?? [])
|
||||
const selectedSubjects = [
|
||||
...specificGroups.map(groupToSubject),
|
||||
...specificMembers.map(memberToSubject),
|
||||
]
|
||||
const hasResults = pages.length > 0 && subjects.length > 0
|
||||
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
|
||||
const hasMore = pages[pages.length - 1]?.hasMore ?? false
|
||||
|
||||
useEffect(() => {
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]!.isIntersecting && !isLoading && hasMore)
|
||||
fetchNextPage()
|
||||
}, { root: scrollRootRef.current, rootMargin: '20px' })
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, fetchNextPage, hasMore])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen)
|
||||
setKeyword('')
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press')
|
||||
setKeyword(inputValue)
|
||||
}
|
||||
|
||||
const handleValueChange = (nextSubjects: Subject[]) => {
|
||||
const nextGroups: AccessControlGroup[] = []
|
||||
const nextMembers: AccessControlAccount[] = []
|
||||
|
||||
for (const subject of nextSubjects) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
nextGroups.push((subject as SubjectGroup).groupData)
|
||||
else
|
||||
nextMembers.push((subject as SubjectAccount).accountData)
|
||||
}
|
||||
|
||||
setSpecificGroups(nextGroups)
|
||||
setSpecificMembers(nextMembers)
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox<Subject, true>
|
||||
multiple
|
||||
open={open}
|
||||
value={selectedSubjects}
|
||||
inputValue={keyword}
|
||||
items={subjects}
|
||||
itemToStringLabel={getSubjectLabel}
|
||||
itemToStringValue={getSubjectValue}
|
||||
isItemEqualToValue={isSameSubject}
|
||||
filter={null}
|
||||
onOpenChange={handleOpenChange}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
aria-label={t('operation.add', { ns: 'common' })}
|
||||
icon={false}
|
||||
size="small"
|
||||
className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<RiAddCircleFill className="size-4" aria-hidden="true" />
|
||||
<span>{t('operation.add', { ns: 'common' })}</span>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="bottom-end"
|
||||
alignOffset={300}
|
||||
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<ComboboxInput
|
||||
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
{isLoading
|
||||
? (
|
||||
<ComboboxStatus className="p-1">
|
||||
<Loading />
|
||||
</ComboboxStatus>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{shouldShowBreadcrumb && (
|
||||
<div className="flex h-7 items-center px-2 py-0.5">
|
||||
<SelectedGroupsBreadCrumb />
|
||||
</div>
|
||||
)}
|
||||
{hasResults
|
||||
? (
|
||||
<>
|
||||
<ComboboxList className="max-h-none p-1">
|
||||
{(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
|
||||
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
|
||||
</ComboboxEmpty>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
function groupToSubject(group: AccessControlGroup): SubjectGroup {
|
||||
return {
|
||||
subjectId: group.id,
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: group,
|
||||
}
|
||||
}
|
||||
|
||||
function memberToSubject(member: AccessControlAccount): SubjectAccount {
|
||||
return {
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: member,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubjectLabel(subject: Subject) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return (subject as SubjectGroup).groupData.name
|
||||
|
||||
return (subject as SubjectAccount).accountData.name
|
||||
}
|
||||
|
||||
function getSubjectValue(subject: Subject) {
|
||||
return `${subject.subjectType}:${subject.subjectId}`
|
||||
}
|
||||
|
||||
function isSameSubject(item: Subject, value: Subject) {
|
||||
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
|
||||
}
|
||||
|
||||
function SubjectItem({ subject }: { subject: Subject }) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} />
|
||||
|
||||
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
|
||||
}
|
||||
|
||||
function SelectedGroupsBreadCrumb() {
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleBreadCrumbClick = (index: number) => {
|
||||
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||
setSelectedGroupsForBreadcrumb(newGroups)
|
||||
}
|
||||
const handleReset = () => {
|
||||
setSelectedGroupsForBreadcrumb([])
|
||||
}
|
||||
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
|
||||
{hasBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
|
||||
)}
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
|
||||
<span>/</span>
|
||||
{isLastGroup
|
||||
? <span>{group.name}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={() => handleBreadCrumbClick(index)}
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
subject: Subject
|
||||
}
|
||||
function GroupItem({ group, subject }: GroupItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const isChecked = specificGroups.some(g => g.id === group.id)
|
||||
|
||||
const handleExpandClick = () => {
|
||||
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
|
||||
<BaseItem subject={subject}>
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
|
||||
</ComboboxItemText>
|
||||
</BaseItem>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isChecked}
|
||||
variant="ghost-accent"
|
||||
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
|
||||
onPointerDown={event => event.preventDefault()}
|
||||
onClick={handleExpandClick}
|
||||
>
|
||||
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
|
||||
<RiArrowRightSLine className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
subject: Subject
|
||||
}
|
||||
function MemberItem({ member, subject }: MemberItemProps) {
|
||||
const currentUser = useSelector(s => s.userProfile)
|
||||
const { t } = useTranslation()
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const isChecked = specificMembers.some(m => m.id === member.id)
|
||||
return (
|
||||
<BaseItem subject={subject} className="pr-3">
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<Avatar size="xxs" avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
|
||||
{currentUser.email === member.email && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
(
|
||||
{t('you', { ns: 'common' })}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
className?: string
|
||||
subject: Subject
|
||||
children: React.ReactNode
|
||||
}
|
||||
function BaseItem({ children, className, subject }: BaseItemProps) {
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={subject}
|
||||
className={cn(
|
||||
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ComboboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionBox({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
|
||||
checked
|
||||
? 'bg-components-checkbox-bg text-components-checkbox-icon'
|
||||
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
|
||||
)}
|
||||
>
|
||||
{checked && <span className="i-ri-check-line size-3" />}
|
||||
</span>
|
||||
<AccessSubjectAddButton
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
breadcrumbGroups={selectedGroupsForBreadcrumb}
|
||||
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
'use client'
|
||||
import type { Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -13,8 +10,7 @@ import { useUpdateAccessMode } from '@/service/access-control'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||
import AccessControlDialogContent from './access-control-dialog-content'
|
||||
|
||||
type AccessControlProps = {
|
||||
app: App
|
||||
@ -31,7 +27,7 @@ export default function AccessControl(props: AccessControlProps) {
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
const hideTip = systemFeatures.webapp_auth.enabled
|
||||
const hideExternalTip = systemFeatures.webapp_auth.enabled
|
||||
&& (systemFeatures.webapp_auth.allow_sso
|
||||
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||
@ -67,47 +63,12 @@ export default function AccessControl(props: AccessControlProps) {
|
||||
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
return (
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t('accessControlDialog.title', { ns: 'app' })}</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">{t('accessControlDialog.description', { ns: 'app' })}</DialogDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 px-6 pb-3">
|
||||
<div className="leading-6">
|
||||
<p className="system-sm-medium text-text-tertiary">{t('accessControlDialog.accessLabel', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<RiBuildingLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.organization', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<SpecificGroupsOrMembers />
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<RiVerifiedBadgeLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.external', { ns: 'app' })}</p>
|
||||
</div>
|
||||
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<div className="flex items-center gap-x-2 p-3">
|
||||
<RiGlobalLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
|
||||
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button disabled={isPending} loading={isPending} variant="primary" onClick={handleConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AccessControlDialogContent
|
||||
hideExternalTip={hideExternalTip}
|
||||
saving={isPending}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</AccessControlDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,34 +1,46 @@
|
||||
'use client'
|
||||
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessSubjectSelectionList } from '@/app/components/base/access-subject-selector'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import { Infotip } from '../../base/infotip'
|
||||
import Loading from '../../base/loading'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
|
||||
export default function SpecificGroupsOrMembers() {
|
||||
export type SpecificGroupsOrMembersProps = {
|
||||
loadSubjects?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function SpecificGroupsOrMembers({
|
||||
loadSubjects = true,
|
||||
loading = false,
|
||||
}: SpecificGroupsOrMembersProps) {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const appId = useAccessControlStore(s => s.appId)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const { isPending, data } = useAppWhiteListSubjects(
|
||||
appId,
|
||||
loadSubjects && Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!loadSubjects)
|
||||
return
|
||||
setSpecificGroups(data?.groups ?? [])
|
||||
setSpecificMembers(data?.members ?? [])
|
||||
}, [data, setSpecificGroups, setSpecificMembers])
|
||||
}, [data, loadSubjects, setSpecificGroups, setSpecificMembers])
|
||||
|
||||
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
return (
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -39,7 +51,7 @@ export default function SpecificGroupsOrMembers() {
|
||||
<div>
|
||||
<div className="flex items-center gap-x-1 p-3">
|
||||
<div className="flex grow items-center gap-x-1">
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
@ -47,101 +59,20 @@ export default function SpecificGroupsOrMembers() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<div className="flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2">
|
||||
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
|
||||
</div>
|
||||
<AccessSubjectSelectionList
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
loading={loadSubjects ? isPending : loading}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderGroupsAndMembers() {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
if (specificGroups.length <= 0 && specificMembers.length <= 0)
|
||||
return <div className="px-2 pt-5 pb-1.5"><p className="text-center system-xs-regular text-text-tertiary">{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}</p></div>
|
||||
return (
|
||||
<>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.groups', { ns: 'app', count: specificGroups.length ?? 0 })}</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
|
||||
</div>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.members', { ns: 'app', count: specificMembers.length ?? 0 })}</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
function GroupItem({ group }: GroupItemProps) {
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const handleRemoveGroup = useCallback(() => {
|
||||
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
|
||||
}, [group, setSpecificGroups, specificGroups])
|
||||
return (
|
||||
<BaseItem
|
||||
icon={<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" />}
|
||||
onRemove={handleRemoveGroup}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{group.name}</p>
|
||||
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
function MemberItem({ member }: MemberItemProps) {
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const handleRemoveMember = useCallback(() => {
|
||||
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
|
||||
}, [member, setSpecificMembers, specificMembers])
|
||||
return (
|
||||
<BaseItem
|
||||
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
|
||||
onRemove={handleRemoveMember}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{member.name}</p>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onRemove?: () => void
|
||||
}
|
||||
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
|
||||
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
aria-label={t('operation.remove', { ns: 'common' })}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebAppSSONotEnabledTip() {
|
||||
const { t } = useTranslation()
|
||||
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AccessControlOptionCard from '../index'
|
||||
|
||||
describe('AccessControlOptionCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render selected content with selected styles', () => {
|
||||
render(
|
||||
<AccessControlOptionCard selected>
|
||||
<span>Selected access</span>
|
||||
</AccessControlOptionCard>,
|
||||
)
|
||||
|
||||
const card = screen.getByText('Selected access').parentElement
|
||||
|
||||
expect(card).toHaveClass('border-components-option-card-option-selected-border')
|
||||
expect(card).toHaveClass('bg-components-option-card-option-selected-bg')
|
||||
})
|
||||
|
||||
it('should call onSelect when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<AccessControlOptionCard onSelect={onSelect}>
|
||||
<span>Selectable access</span>
|
||||
</AccessControlOptionCard>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Selectable access' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSelect from keyboard activation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<AccessControlOptionCard onSelect={onSelect}>
|
||||
<span>Keyboard access</span>
|
||||
</AccessControlOptionCard>,
|
||||
)
|
||||
|
||||
const card = screen.getByRole('button', { name: 'Keyboard access' })
|
||||
card.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
await user.keyboard(' ')
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should not call onSelect when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<AccessControlOptionCard disabled onSelect={onSelect}>
|
||||
<span>Disabled access</span>
|
||||
</AccessControlOptionCard>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Disabled access'))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Disabled access').parentElement).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
59
web/app/components/base/access-control-option-card/index.tsx
Normal file
59
web/app/components/base/access-control-option-card/index.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentPropsWithoutRef, KeyboardEvent } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
type AccessControlOptionCardProps = Omit<ComponentPropsWithoutRef<'div'>, 'onSelect'> & {
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
export default function AccessControlOptionCard({
|
||||
selected = false,
|
||||
disabled = false,
|
||||
className,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onSelect,
|
||||
...props
|
||||
}: AccessControlOptionCardProps) {
|
||||
const interactive = Boolean(onSelect) && !disabled
|
||||
|
||||
const handleClick: ComponentPropsWithoutRef<'div'>['onClick'] = (event) => {
|
||||
onClick?.(event)
|
||||
if (!event.defaultPrevented && interactive)
|
||||
onSelect?.()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented || !interactive)
|
||||
return
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onSelect?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-pressed={interactive ? selected : undefined}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
selected
|
||||
? 'rounded-[10px] border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm'
|
||||
: 'rounded-[10px] border border-components-option-card-option-border bg-components-option-card-option-bg',
|
||||
interactive && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
607
web/app/components/base/access-subject-selector/index.tsx
Normal file
607
web/app/components/base/access-subject-selector/index.tsx
Normal file
@ -0,0 +1,607 @@
|
||||
'use client'
|
||||
|
||||
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
AccessControlAccount,
|
||||
AccessControlGroup,
|
||||
Subject,
|
||||
SubjectAccount,
|
||||
SubjectGroup,
|
||||
} from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
import Loading from '../loading'
|
||||
|
||||
export type AccessSubjectSelectionValue = {
|
||||
groups: AccessControlGroup[]
|
||||
members: AccessControlAccount[]
|
||||
}
|
||||
|
||||
type AccessSubjectSelectionProps = {
|
||||
selectedGroups: AccessControlGroup[]
|
||||
selectedMembers: AccessControlAccount[]
|
||||
onChange: (value: AccessSubjectSelectionValue) => void
|
||||
}
|
||||
|
||||
type AccessSubjectAddButtonProps = AccessSubjectSelectionProps & {
|
||||
disabled?: boolean
|
||||
breadcrumbGroups?: AccessControlGroup[]
|
||||
onBreadcrumbGroupsChange?: (groups: AccessControlGroup[]) => void
|
||||
}
|
||||
|
||||
export function AccessSubjectAddButton({
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
disabled,
|
||||
breadcrumbGroups,
|
||||
onBreadcrumbGroupsChange,
|
||||
}: AccessSubjectAddButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [internalBreadcrumbGroups, setInternalBreadcrumbGroups] = useState<AccessControlGroup[]>([])
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const selectedGroupsForBreadcrumb = breadcrumbGroups ?? internalBreadcrumbGroups
|
||||
const setSelectedGroupsForBreadcrumb = onBreadcrumbGroupsChange ?? setInternalBreadcrumbGroups
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({
|
||||
keyword: debouncedKeyword,
|
||||
groupId: lastAvailableGroup?.id,
|
||||
resultsPerPage: 10,
|
||||
}, open && !disabled)
|
||||
const pages = data?.pages ?? []
|
||||
const subjects = pages.flatMap(page => page.subjects ?? [])
|
||||
const selectedSubjects = [
|
||||
...selectedGroups.map(groupToSubject),
|
||||
...selectedMembers.map(memberToSubject),
|
||||
]
|
||||
const hasResults = pages.length > 0 && subjects.length > 0
|
||||
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
|
||||
const hasMore = pages[pages.length - 1]?.hasMore ?? false
|
||||
|
||||
useEffect(() => {
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]!.isIntersecting && !isLoading && hasMore)
|
||||
fetchNextPage()
|
||||
}, { root: scrollRootRef.current, rootMargin: '20px' })
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, fetchNextPage, hasMore])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen && disabled)
|
||||
return
|
||||
if (!nextOpen)
|
||||
setKeyword('')
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
|
||||
if (!disabled && details.reason !== 'item-press')
|
||||
setKeyword(inputValue)
|
||||
}
|
||||
|
||||
const handleValueChange = (nextSubjects: Subject[]) => {
|
||||
const nextGroups: AccessControlGroup[] = []
|
||||
const nextMembers: AccessControlAccount[] = []
|
||||
|
||||
for (const subject of nextSubjects) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
nextGroups.push((subject as SubjectGroup).groupData)
|
||||
else
|
||||
nextMembers.push((subject as SubjectAccount).accountData)
|
||||
}
|
||||
|
||||
onChange({
|
||||
groups: nextGroups,
|
||||
members: nextMembers,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox<Subject, true>
|
||||
multiple
|
||||
open={open}
|
||||
value={selectedSubjects}
|
||||
inputValue={keyword}
|
||||
items={subjects}
|
||||
disabled={disabled}
|
||||
itemToStringLabel={getSubjectLabel}
|
||||
itemToStringValue={getSubjectValue}
|
||||
isItemEqualToValue={isSameSubject}
|
||||
filter={null}
|
||||
onOpenChange={handleOpenChange}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
aria-label={t('operation.add', { ns: 'common' })}
|
||||
icon={false}
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
|
||||
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0">{t('operation.add', { ns: 'common' })}</span>
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="bottom-end"
|
||||
alignOffset={300}
|
||||
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<ComboboxInput
|
||||
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
{isLoading
|
||||
? (
|
||||
<ComboboxStatus className="p-1">
|
||||
<Loading />
|
||||
</ComboboxStatus>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{shouldShowBreadcrumb && (
|
||||
<div className="flex h-7 items-center px-2 py-0.5">
|
||||
<SelectedGroupsBreadCrumb
|
||||
selectedGroupsForBreadcrumb={selectedGroupsForBreadcrumb}
|
||||
onChange={setSelectedGroupsForBreadcrumb}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasResults
|
||||
? (
|
||||
<>
|
||||
<ComboboxList className="max-h-none p-1">
|
||||
{(subject: Subject) => (
|
||||
<SubjectItem
|
||||
key={getSubjectValue(subject)}
|
||||
subject={subject}
|
||||
selectedGroups={selectedGroups}
|
||||
selectedMembers={selectedMembers}
|
||||
onExpandGroup={group => setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])}
|
||||
/>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
|
||||
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
|
||||
</ComboboxEmpty>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
type AccessSubjectSelectionListProps = AccessSubjectSelectionProps & {
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AccessSubjectSelectionList({
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
loading,
|
||||
className,
|
||||
}: AccessSubjectSelectionListProps) {
|
||||
return (
|
||||
<div className={cn('flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2', className)}>
|
||||
{loading
|
||||
? <Loading />
|
||||
: (
|
||||
<RenderGroupsAndMembers
|
||||
selectedGroups={selectedGroups}
|
||||
selectedMembers={selectedMembers}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderGroupsAndMembers({
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
}: AccessSubjectSelectionProps) {
|
||||
const { t } = useTranslation()
|
||||
if (selectedGroups.length <= 0 && selectedMembers.length <= 0) {
|
||||
return (
|
||||
<div className="px-2 pt-5 pb-1.5">
|
||||
<p className="text-center system-xs-regular text-text-tertiary">
|
||||
{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })}
|
||||
</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{selectedGroups.map(group => (
|
||||
<SelectedGroupItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
selectedGroups={selectedGroups}
|
||||
selectedMembers={selectedMembers}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })}
|
||||
</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{selectedMembers.map(member => (
|
||||
<SelectedMemberItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
selectedGroups={selectedGroups}
|
||||
selectedMembers={selectedMembers}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function groupToSubject(group: AccessControlGroup): SubjectGroup {
|
||||
return {
|
||||
subjectId: group.id,
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: group,
|
||||
}
|
||||
}
|
||||
|
||||
function memberToSubject(member: AccessControlAccount): SubjectAccount {
|
||||
return {
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: member,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubjectLabel(subject: Subject) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return (subject as SubjectGroup).groupData.name
|
||||
|
||||
return (subject as SubjectAccount).accountData.name
|
||||
}
|
||||
|
||||
function getSubjectValue(subject: Subject) {
|
||||
return `${subject.subjectType}:${subject.subjectId}`
|
||||
}
|
||||
|
||||
function isSameSubject(item: Subject, value: Subject) {
|
||||
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
|
||||
}
|
||||
|
||||
function SubjectItem({
|
||||
subject,
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onExpandGroup,
|
||||
}: {
|
||||
subject: Subject
|
||||
selectedGroups: AccessControlGroup[]
|
||||
selectedMembers: AccessControlAccount[]
|
||||
onExpandGroup: (group: AccessControlGroup) => void
|
||||
}) {
|
||||
if (subject.subjectType === SubjectType.GROUP) {
|
||||
return (
|
||||
<GroupItem
|
||||
group={(subject as SubjectGroup).groupData}
|
||||
subject={subject}
|
||||
selectedGroups={selectedGroups}
|
||||
onExpandGroup={onExpandGroup}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MemberItem
|
||||
member={(subject as SubjectAccount).accountData}
|
||||
subject={subject}
|
||||
selectedMembers={selectedMembers}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectedGroupsBreadCrumb({
|
||||
selectedGroupsForBreadcrumb,
|
||||
onChange,
|
||||
}: {
|
||||
selectedGroupsForBreadcrumb: AccessControlGroup[]
|
||||
onChange: (groups: AccessControlGroup[]) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleBreadCrumbClick = (index: number) => {
|
||||
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||
onChange(newGroups)
|
||||
}
|
||||
const handleReset = () => {
|
||||
onChange([])
|
||||
}
|
||||
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
|
||||
{hasBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
|
||||
)}
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
|
||||
|
||||
return (
|
||||
<div key={group.id} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
|
||||
<span>/</span>
|
||||
{isLastGroup
|
||||
? <span>{group.name}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={() => handleBreadCrumbClick(index)}
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
subject: Subject
|
||||
selectedGroups: AccessControlGroup[]
|
||||
onExpandGroup: (group: AccessControlGroup) => void
|
||||
}
|
||||
|
||||
function GroupItem({ group, subject, selectedGroups, onExpandGroup }: GroupItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const isChecked = selectedGroups.some(selectedGroup => selectedGroup.id === group.id)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
|
||||
<ComboboxBaseItem subject={subject}>
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
|
||||
<span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
|
||||
</ComboboxItemText>
|
||||
</ComboboxBaseItem>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isChecked}
|
||||
variant="ghost-accent"
|
||||
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
|
||||
onPointerDown={event => event.preventDefault()}
|
||||
onClick={() => onExpandGroup(group)}
|
||||
>
|
||||
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
|
||||
<span className="i-ri-arrow-right-s-line size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
subject: Subject
|
||||
selectedMembers: AccessControlAccount[]
|
||||
}
|
||||
|
||||
function MemberItem({ member, subject, selectedMembers }: MemberItemProps) {
|
||||
const currentUser = useSelector(s => s.userProfile)
|
||||
const { t } = useTranslation()
|
||||
const isChecked = selectedMembers.some(selectedMember => selectedMember.id === member.id)
|
||||
return (
|
||||
<ComboboxBaseItem subject={subject} className="pr-3">
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
|
||||
<Avatar size="xxs" avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
|
||||
{currentUser.email === member.email && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
(
|
||||
{t('you', { ns: 'common' })}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
|
||||
</ComboboxBaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type ComboboxBaseItemProps = {
|
||||
className?: string
|
||||
subject: Subject
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function ComboboxBaseItem({ children, className, subject }: ComboboxBaseItemProps) {
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={subject}
|
||||
className={cn(
|
||||
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ComboboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionBox({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
|
||||
checked
|
||||
? 'bg-components-checkbox-bg text-components-checkbox-icon'
|
||||
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
|
||||
)}
|
||||
>
|
||||
{checked && <span className="i-ri-check-line size-3" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectedGroupItemProps = AccessSubjectSelectionProps & {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
|
||||
function SelectedGroupItem({
|
||||
group,
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
}: SelectedGroupItemProps) {
|
||||
const handleRemoveGroup = () => {
|
||||
onChange({
|
||||
groups: selectedGroups.filter(selectedGroup => selectedGroup.id !== group.id),
|
||||
members: selectedMembers,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectedBaseItem
|
||||
icon={<span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />}
|
||||
onRemove={handleRemoveGroup}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{group.name}</p>
|
||||
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
|
||||
</SelectedBaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectedMemberItemProps = AccessSubjectSelectionProps & {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
|
||||
function SelectedMemberItem({
|
||||
member,
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
}: SelectedMemberItemProps) {
|
||||
const handleRemoveMember = () => {
|
||||
onChange({
|
||||
groups: selectedGroups,
|
||||
members: selectedMembers.filter(selectedMember => selectedMember.id !== member.id),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectedBaseItem
|
||||
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
|
||||
onRemove={handleRemoveMember}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{member.name}</p>
|
||||
</SelectedBaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectedBaseItemProps = {
|
||||
icon: ReactNode
|
||||
children: ReactNode
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
function SelectedBaseItem({ icon, onRemove, children }: SelectedBaseItemProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
|
||||
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
aria-label={t('operation.remove', { ns: 'common' })}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<span className="i-ri-close-circle-fill h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -5,43 +5,24 @@ import type {
|
||||
AccessSubject,
|
||||
Environment,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { AccessSubjectSelectionValue } from '@/app/components/base/access-subject-selector'
|
||||
import type {
|
||||
AccessControlAccount,
|
||||
AccessControlGroup,
|
||||
Subject as AccessControlSubject,
|
||||
SubjectAccount as AccessControlSubjectAccount,
|
||||
SubjectGroup as AccessControlSubjectGroup,
|
||||
} from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChipRemove,
|
||||
ComboboxChips,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxInputTrigger,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxStatus,
|
||||
ComboboxValue,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { SubjectType as AccessControlSubjectType } from '@/models/access-control'
|
||||
import AccessControlDialog from '@/app/components/app/app-access-control/access-control-dialog'
|
||||
import AccessControlDialogContent from '@/app/components/app/app-access-control/access-control-dialog-content'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { SubjectType as AccessControlSubjectType, AccessMode as AppAccessMode } from '@/models/access-control'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { environmentName } from '../../../environment'
|
||||
@ -51,17 +32,15 @@ import {
|
||||
} from '../../table'
|
||||
|
||||
type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
|
||||
type AccessMode = NonNullable<AccessPolicy['mode']>
|
||||
type AccessPolicyMode = NonNullable<AccessPolicy['mode']>
|
||||
type AccessSubjectType = NonNullable<AccessSubject['subjectType']>
|
||||
|
||||
const ACCESS_MODE_PUBLIC = 'ACCESS_MODE_PUBLIC' satisfies AccessMode
|
||||
const ACCESS_MODE_PRIVATE = 'ACCESS_MODE_PRIVATE' satisfies AccessMode
|
||||
const ACCESS_MODE_PRIVATE_ALL = 'ACCESS_MODE_PRIVATE_ALL' satisfies AccessMode
|
||||
const ACCESS_MODE_PUBLIC = 'ACCESS_MODE_PUBLIC' satisfies AccessPolicyMode
|
||||
const ACCESS_MODE_PRIVATE = 'ACCESS_MODE_PRIVATE' satisfies AccessPolicyMode
|
||||
const ACCESS_MODE_PRIVATE_ALL = 'ACCESS_MODE_PRIVATE_ALL' satisfies AccessPolicyMode
|
||||
const SUBJECT_TYPE_ACCOUNT = 'SUBJECT_TYPE_ACCOUNT' satisfies AccessSubjectType
|
||||
const SUBJECT_TYPE_GROUP = 'SUBJECT_TYPE_GROUP' satisfies AccessSubjectType
|
||||
const ACCESS_SUBJECT_LABEL_PAGE_SIZE = 100
|
||||
const ACCESS_SUBJECT_SEARCH_PAGE_SIZE = 50
|
||||
const ACCESS_SUBJECT_SEARCH_DEBOUNCE = 300
|
||||
|
||||
function accessModeToPermissionKey(mode?: AccessPolicy['mode']): AccessPermissionKind {
|
||||
if (mode === ACCESS_MODE_PRIVATE)
|
||||
@ -71,7 +50,7 @@ function accessModeToPermissionKey(mode?: AccessPolicy['mode']): AccessPermissio
|
||||
return 'organization'
|
||||
}
|
||||
|
||||
function permissionKeyToAccessMode(key: AccessPermissionKind): AccessMode {
|
||||
function permissionKeyToAccessMode(key: AccessPermissionKind): AccessPolicyMode {
|
||||
if (key === 'organization')
|
||||
return ACCESS_MODE_PRIVATE_ALL
|
||||
if (key === 'specific')
|
||||
@ -79,69 +58,28 @@ function permissionKeyToAccessMode(key: AccessPermissionKind): AccessMode {
|
||||
return ACCESS_MODE_PUBLIC
|
||||
}
|
||||
|
||||
function permissionKeyToAppAccessMode(key: AccessPermissionKind): AppAccessMode {
|
||||
if (key === 'organization')
|
||||
return AppAccessMode.ORGANIZATION
|
||||
if (key === 'specific')
|
||||
return AppAccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
return AppAccessMode.PUBLIC
|
||||
}
|
||||
|
||||
function appAccessModeToPermissionKey(mode: AppAccessMode): AccessPermissionKind {
|
||||
if (mode === AppAccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
return 'specific'
|
||||
if (mode === AppAccessMode.PUBLIC)
|
||||
return 'anyone'
|
||||
return 'organization'
|
||||
}
|
||||
|
||||
const permissionIcon: Record<AccessPermissionKind, string> = {
|
||||
organization: 'i-ri-team-line',
|
||||
specific: 'i-ri-lock-line',
|
||||
anyone: 'i-ri-global-line',
|
||||
}
|
||||
|
||||
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
|
||||
|
||||
function PermissionPicker({ value, disabled, loading, onChange }: {
|
||||
value: AccessPermissionKind
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
onChange: (kind: AccessPermissionKind) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const icon = permissionIcon[value]
|
||||
const label = t(`access.permission.${value}`)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2.5 system-sm-regular text-text-secondary hover:bg-state-base-hover',
|
||||
disabled && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<span className={cn(icon, 'size-4 shrink-0 text-text-tertiary')} />
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
<span className={cn(loading ? 'i-ri-loader-2-line animate-spin' : 'i-ri-arrow-down-s-line', 'size-4 shrink-0 text-text-tertiary motion-reduce:animate-none')} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-85 p-1">
|
||||
{permissionOrder.map((kind) => {
|
||||
const itemIcon = permissionIcon[kind]
|
||||
const isSelected = kind === value
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={kind}
|
||||
onClick={() => onChange(kind)}
|
||||
className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2"
|
||||
>
|
||||
<span className={cn(itemIcon, 'mt-0.5 size-4 shrink-0 text-text-tertiary')} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate system-sm-medium text-text-primary">
|
||||
{t(`access.permission.${kind}`)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t(`access.permission.${kind}Desc`)}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="mt-0.5 i-ri-check-line size-4 shrink-0 text-text-accent" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectableAccessSubject = {
|
||||
id: string
|
||||
subjectType: AccessSubjectType
|
||||
@ -176,24 +114,10 @@ function normalizeSubject(subject: AccessControlSubject): SelectableAccessSubjec
|
||||
}
|
||||
}
|
||||
|
||||
function subjectKey(subject: Pick<SelectableAccessSubject, 'id' | 'subjectType'>) {
|
||||
return `${subject.subjectType}:${subject.id}`
|
||||
}
|
||||
|
||||
function getSubjectLabel(subject: SelectableAccessSubject) {
|
||||
return subject.name || subject.id
|
||||
}
|
||||
|
||||
function getSubjectValue(subject: SelectableAccessSubject) {
|
||||
return subjectKey(subject)
|
||||
}
|
||||
|
||||
function isSameSubject(item: SelectableAccessSubject, value: SelectableAccessSubject) {
|
||||
return item.id === value.id && item.subjectType === value.subjectType
|
||||
}
|
||||
|
||||
const SUBJECT_PICKER_SKELETON_KEYS = ['first-subject', 'second-subject', 'third-subject']
|
||||
|
||||
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
|
||||
return subjects.map(subject => ({
|
||||
subjectId: subject.id,
|
||||
@ -219,198 +143,215 @@ function selectedSubjectsFromPolicy(policy?: AccessPolicy, labelSubjects: Select
|
||||
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
|
||||
}
|
||||
|
||||
function SubjectIcon({ subject }: {
|
||||
subject: SelectableAccessSubject
|
||||
}) {
|
||||
const isGroup = subject.subjectType === SUBJECT_TYPE_GROUP
|
||||
|
||||
return (
|
||||
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'size-3.5 shrink-0 text-text-tertiary')} aria-hidden="true" />
|
||||
)
|
||||
function selectableSubjectToGroup(subject: SelectableAccessSubject): AccessControlGroup {
|
||||
return {
|
||||
id: subject.id,
|
||||
name: getSubjectLabel(subject),
|
||||
groupSize: subject.memberCount ?? 0,
|
||||
} as unknown as AccessControlGroup
|
||||
}
|
||||
|
||||
type AccessSubjectComboboxProps = {
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
selectedSubjects: SelectableAccessSubject[]
|
||||
onChange: (subjects: SelectableAccessSubject[]) => void
|
||||
function selectableSubjectToAccount(subject: SelectableAccessSubject): AccessControlAccount {
|
||||
const label = getSubjectLabel(subject)
|
||||
|
||||
return {
|
||||
id: subject.id,
|
||||
name: label,
|
||||
email: label,
|
||||
avatar: '',
|
||||
avatarUrl: '',
|
||||
} as unknown as AccessControlAccount
|
||||
}
|
||||
|
||||
function AccessSubjectCombobox({
|
||||
function accessControlSelectionFromSubjects(subjects: SelectableAccessSubject[]): AccessSubjectSelectionValue {
|
||||
return {
|
||||
groups: subjects
|
||||
.filter(subject => subject.subjectType === SUBJECT_TYPE_GROUP)
|
||||
.map(selectableSubjectToGroup),
|
||||
members: subjects
|
||||
.filter(subject => subject.subjectType === SUBJECT_TYPE_ACCOUNT)
|
||||
.map(selectableSubjectToAccount),
|
||||
}
|
||||
}
|
||||
|
||||
function subjectsFromAccessControlSelection(value: AccessSubjectSelectionValue): SelectableAccessSubject[] {
|
||||
return [
|
||||
...value.groups.map((group): SelectableAccessSubject => ({
|
||||
id: group.id,
|
||||
subjectType: SUBJECT_TYPE_GROUP,
|
||||
name: group.name,
|
||||
memberCount: group.groupSize,
|
||||
})),
|
||||
...value.members.map((member): SelectableAccessSubject => ({
|
||||
id: member.id,
|
||||
subjectType: SUBJECT_TYPE_ACCOUNT,
|
||||
name: member.name || member.email,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function PermissionSummaryButton({
|
||||
value,
|
||||
disabled,
|
||||
loading,
|
||||
selectedSubjects,
|
||||
onChange,
|
||||
}: AccessSubjectComboboxProps) {
|
||||
environmentLabel,
|
||||
onClick,
|
||||
}: {
|
||||
value: AccessPermissionKind
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
environmentLabel: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: ACCESS_SUBJECT_SEARCH_DEBOUNCE })
|
||||
const trimmedKeyword = keyword.trim()
|
||||
const searchKeyword = debouncedKeyword.trim()
|
||||
const isSearchDebouncing = trimmedKeyword !== searchKeyword
|
||||
const isInteractionDisabled = Boolean(disabled || loading)
|
||||
const subjectsQuery = useSearchForWhiteListCandidates({
|
||||
keyword: searchKeyword || undefined,
|
||||
resultsPerPage: ACCESS_SUBJECT_SEARCH_PAGE_SIZE,
|
||||
}, open && !isInteractionDisabled)
|
||||
const candidateSubjects = subjectsQuery.data?.pages.flatMap(page => page.subjects ?? []) ?? []
|
||||
const subjects = isSearchDebouncing
|
||||
? []
|
||||
: candidateSubjects
|
||||
.map(normalizeSubject)
|
||||
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
|
||||
const selectedItems = selectedSubjects.filter(selectedSubject =>
|
||||
!subjects.some(subject => isSameSubject(subject, selectedSubject)),
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t('access.permissions.editAriaLabel', { environment: environmentLabel })}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2.5 system-sm-regular text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
disabled && 'cursor-not-allowed opacity-50 hover:bg-components-input-bg-normal',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
loading ? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none' : permissionIcon[value],
|
||||
'size-4 shrink-0 text-text-tertiary',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">{t(`access.permission.${value}`)}</span>
|
||||
<span className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
const items = [...subjects, ...selectedItems]
|
||||
const isResultLoading = subjectsQuery.isLoading || isSearchDebouncing
|
||||
const shouldShowEmpty = !isResultLoading && !subjectsQuery.isError && subjects.length === 0
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen && isInteractionDisabled)
|
||||
return
|
||||
if (!nextOpen)
|
||||
setKeyword('')
|
||||
setOpen(nextOpen)
|
||||
function SubjectsSummary({
|
||||
permissionKind,
|
||||
subjects,
|
||||
loading,
|
||||
}: {
|
||||
permissionKind: AccessPermissionKind
|
||||
subjects: SelectableAccessSubject[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
if (permissionKind !== 'specific') {
|
||||
return (
|
||||
<div className="flex min-h-8 items-center system-xs-regular text-text-tertiary">
|
||||
<span className="min-w-0">
|
||||
{t(`access.permission.${permissionKind}Desc`)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
|
||||
if (!isInteractionDisabled && details.reason !== 'item-press')
|
||||
setKeyword(inputValue)
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-8 items-center">
|
||||
<SkeletonRectangle className="h-4 w-36 animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleValueChange = (nextSubjects: SelectableAccessSubject[]) => {
|
||||
if (isInteractionDisabled)
|
||||
const groupCount = subjects.filter(subject => subject.subjectType === SUBJECT_TYPE_GROUP).length
|
||||
const memberCount = subjects.length - groupCount
|
||||
const countLabels = [
|
||||
groupCount > 0 ? t('access.members.groupCount', { count: groupCount }) : undefined,
|
||||
memberCount > 0 ? t('access.members.memberCount', { count: memberCount }) : undefined,
|
||||
].filter((label): label is string => Boolean(label))
|
||||
|
||||
return (
|
||||
<div className="flex min-h-8 min-w-0 items-center gap-1.5 system-xs-regular text-text-tertiary">
|
||||
<span className="i-ri-lock-line size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">
|
||||
{countLabels.length > 0 ? countLabels.join(' · ') : t('access.permission.specificDesc')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentAccessControlDialog({
|
||||
open,
|
||||
value,
|
||||
subjects,
|
||||
subjectsLoading,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
value: AccessPermissionKind
|
||||
subjects: AccessSubjectSelectionValue
|
||||
subjectsLoading?: boolean
|
||||
saving?: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (kind: AccessPermissionKind, subjects: AccessSubjectSelectionValue) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const specificSelected = currentMenu === AppAccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
const selectedSubjectCount = specificGroups.length + specificMembers.length
|
||||
const specificEmpty = specificSelected && selectedSubjectCount === 0
|
||||
const confirmDisabled = saving || (specificSelected && (subjectsLoading || specificEmpty))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
setKeyword('')
|
||||
onChange(nextSubjects)
|
||||
setCurrentMenu(permissionKeyToAppAccessMode(value))
|
||||
setSpecificGroups(subjects.groups)
|
||||
setSpecificMembers(subjects.members)
|
||||
setSelectedGroupsForBreadcrumb([])
|
||||
}, [
|
||||
open,
|
||||
setCurrentMenu,
|
||||
setSelectedGroupsForBreadcrumb,
|
||||
setSpecificGroups,
|
||||
setSpecificMembers,
|
||||
subjects.groups,
|
||||
subjects.members,
|
||||
value,
|
||||
])
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (confirmDisabled)
|
||||
return
|
||||
|
||||
onSubmit(
|
||||
appAccessModeToPermissionKey(currentMenu),
|
||||
specificSelected
|
||||
? { groups: specificGroups, members: specificMembers }
|
||||
: { groups: [], members: [] },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox<SelectableAccessSubject, true>
|
||||
multiple
|
||||
open={open}
|
||||
value={selectedSubjects}
|
||||
inputValue={keyword}
|
||||
items={items}
|
||||
disabled={disabled}
|
||||
itemToStringLabel={getSubjectLabel}
|
||||
itemToStringValue={getSubjectValue}
|
||||
isItemEqualToValue={isSameSubject}
|
||||
filter={null}
|
||||
onOpenChange={handleOpenChange}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxInputGroup className="h-auto min-h-8 w-full max-w-full items-start overflow-hidden py-1 pr-1">
|
||||
<ComboboxChips>
|
||||
<ComboboxValue>
|
||||
{(selectedValue: SelectableAccessSubject[]) => (
|
||||
<>
|
||||
{selectedValue.map(subject => (
|
||||
<ComboboxChip
|
||||
key={subjectKey(subject)}
|
||||
className="shrink-0 cursor-default rounded-full border border-divider-subtle bg-components-badge-white-to-dark select-none"
|
||||
>
|
||||
<SubjectIcon subject={subject} />
|
||||
<span className="max-w-32 truncate">{getSubjectLabel(subject)}</span>
|
||||
{subject.subjectType === SUBJECT_TYPE_GROUP && subject.memberCount != null && (
|
||||
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
|
||||
)}
|
||||
<ComboboxChipRemove
|
||||
disabled={isInteractionDisabled}
|
||||
aria-label={t('operation.remove', { ns: 'common' })}
|
||||
>
|
||||
<span className="i-ri-close-circle-fill size-3.5" aria-hidden="true" />
|
||||
</ComboboxChipRemove>
|
||||
</ComboboxChip>
|
||||
))}
|
||||
<ComboboxInput
|
||||
name="access-subjects"
|
||||
disabled={disabled}
|
||||
readOnly={isInteractionDisabled}
|
||||
aria-label={t('access.members.pickPlaceholder')}
|
||||
placeholder={selectedValue.length ? '' : t('access.members.pickPlaceholder')}
|
||||
className={cn('px-1 py-0.5 system-sm-medium', selectedValue.length ? 'min-w-16' : 'min-w-0')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
<ComboboxInputTrigger className="mt-0.5" disabled={isInteractionDisabled}>
|
||||
{loading
|
||||
? (
|
||||
<span
|
||||
className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
</ComboboxInputTrigger>
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent
|
||||
popupClassName="max-w-none p-0 aria-disabled:pointer-events-none"
|
||||
popupProps={{
|
||||
'aria-busy': subjectsQuery.isFetching || isSearchDebouncing || undefined,
|
||||
'aria-disabled': isInteractionDisabled || undefined,
|
||||
<AccessControlDialog show={open} onClose={onClose}>
|
||||
<AccessControlDialogContent
|
||||
title={t('access.permissions.editTitle')}
|
||||
description={t('access.permissions.editDescription')}
|
||||
hideExternal
|
||||
saving={saving}
|
||||
confirmDisabled={confirmDisabled}
|
||||
specificGroupsOrMembersProps={{
|
||||
loadSubjects: false,
|
||||
loading: subjectsLoading,
|
||||
}}
|
||||
>
|
||||
{isResultLoading
|
||||
? (
|
||||
<ComboboxStatus className="flex flex-col gap-2 px-3 py-3">
|
||||
{SUBJECT_PICKER_SKELETON_KEYS.map(key => (
|
||||
<SkeletonRow key={key} className="h-6">
|
||||
<SkeletonRectangle className="h-3 w-full animate-pulse" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</ComboboxStatus>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{subjectsQuery.isFetching && (
|
||||
<ComboboxStatus className="border-b border-divider-subtle px-3 py-2 system-xs-regular">
|
||||
{t('common.loading')}
|
||||
</ComboboxStatus>
|
||||
)}
|
||||
<ComboboxList className="p-1">
|
||||
{items.map(subject => (
|
||||
<ComboboxItem
|
||||
key={subjectKey(subject)}
|
||||
value={subject}
|
||||
className="mx-0"
|
||||
>
|
||||
<ComboboxItemText className="flex items-center gap-2 px-0">
|
||||
<SubjectIcon subject={subject} />
|
||||
<span className="min-w-0 flex-1 truncate">{getSubjectLabel(subject)}</span>
|
||||
{subject.subjectType === SUBJECT_TYPE_GROUP && subject.memberCount != null && (
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
{t('access.members.memberCount', { count: subject.memberCount })}
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
{shouldShowEmpty && (
|
||||
selectedItems.length > 0
|
||||
? (
|
||||
<ComboboxStatus className="px-3 py-5 text-center system-xs-regular">
|
||||
{t('access.members.empty')}
|
||||
</ComboboxStatus>
|
||||
)
|
||||
: (
|
||||
<ComboboxEmpty className="px-3 py-5 text-center system-xs-regular">
|
||||
{t('access.members.empty')}
|
||||
</ComboboxEmpty>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</AccessControlDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -458,6 +399,7 @@ export function EnvironmentPermissionRow({
|
||||
kind?: AccessPermissionKind
|
||||
subjects?: SelectableAccessSubject[]
|
||||
}>({})
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const subjectLabelCandidates = [
|
||||
...(draft.subjects ?? []),
|
||||
...accessSubjects,
|
||||
@ -466,14 +408,23 @@ export function EnvironmentPermissionRow({
|
||||
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
|
||||
const policySelectedSubjects = policyKind === 'specific' ? selectedSubjectsFromPolicy(policy, subjectLabelCandidates) : []
|
||||
const subjects = hasDraft && draft.subjects ? draft.subjects : accessSubjectsQuery.isLoading ? [] : policySelectedSubjects
|
||||
const subjectSelection = accessControlSelectionFromSubjects(subjects)
|
||||
const isSaving = setEnvironmentAccessPolicy.isPending
|
||||
const controlsDisabled = isSaving || accessPolicyQuery.isLoading || accessPolicyQuery.isError
|
||||
const subjectsLoading = permissionKind === 'specific' && accessSubjectsQuery.isLoading
|
||||
const controlsDisabled = isSaving || accessPolicyQuery.isLoading || accessPolicyQuery.isError || subjectsLoading
|
||||
const envName = environmentName(environment)
|
||||
|
||||
const persistPolicy = (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
|
||||
const persistPolicy = (
|
||||
nextKind: AccessPermissionKind,
|
||||
nextSubjects: SelectableAccessSubject[],
|
||||
options?: {
|
||||
onSuccess?: () => void
|
||||
},
|
||||
) => {
|
||||
if (!environmentId)
|
||||
return
|
||||
return false
|
||||
if (nextKind === 'specific' && nextSubjects.length === 0)
|
||||
return
|
||||
return false
|
||||
|
||||
setEnvironmentAccessPolicy.mutate(
|
||||
{
|
||||
@ -489,33 +440,25 @@ export function EnvironmentPermissionRow({
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: () => {
|
||||
toast.error(t('access.permission.updateFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
|
||||
const handlePermissionSubmit = (nextKind: AccessPermissionKind, nextSelection: AccessSubjectSelectionValue) => {
|
||||
const normalizedSubjects = nextKind === 'specific' ? subjectsFromAccessControlSelection(nextSelection) : []
|
||||
setDraft({
|
||||
fingerprint: policyFingerprint,
|
||||
kind: nextKind,
|
||||
subjects: nextKind === 'specific' ? subjects : [],
|
||||
subjects: normalizedSubjects,
|
||||
})
|
||||
if (nextKind === 'specific') {
|
||||
persistPolicy(nextKind, subjects)
|
||||
return
|
||||
}
|
||||
persistPolicy(nextKind, [])
|
||||
}
|
||||
|
||||
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
|
||||
setDraft({
|
||||
fingerprint: policyFingerprint,
|
||||
kind: 'specific',
|
||||
subjects: nextSubjects,
|
||||
persistPolicy(nextKind, normalizedSubjects, {
|
||||
onSuccess: () => setDialogOpen(false),
|
||||
})
|
||||
persistPolicy('specific', nextSubjects)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -526,7 +469,7 @@ export function EnvironmentPermissionRow({
|
||||
</div>
|
||||
<div className="mt-1 flex min-h-8 min-w-0 items-center pc:mt-0">
|
||||
<span className="min-w-0 truncate text-text-primary">
|
||||
{environmentName(environment)}
|
||||
{envName}
|
||||
</span>
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
@ -534,43 +477,34 @@ export function EnvironmentPermissionRow({
|
||||
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
|
||||
{t('access.permissions.col.permission')}
|
||||
</div>
|
||||
<PermissionPicker
|
||||
<PermissionSummaryButton
|
||||
value={permissionKind}
|
||||
disabled={controlsDisabled}
|
||||
loading={isSaving}
|
||||
onChange={handlePermissionChange}
|
||||
environmentLabel={envName}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
/>
|
||||
{dialogOpen && (
|
||||
<DeploymentAccessControlDialog
|
||||
open={dialogOpen}
|
||||
value={permissionKind}
|
||||
subjects={subjectSelection}
|
||||
subjectsLoading={subjectsLoading}
|
||||
saving={isSaving}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onSubmit={handlePermissionSubmit}
|
||||
/>
|
||||
)}
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className="block h-auto max-w-none px-4 pt-1 pb-3 align-top pc:table-cell pc:max-w-[200px] pc:px-2.5 pc:py-[5px] pc:pl-3">
|
||||
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
|
||||
{t('access.permissions.col.subjects')}
|
||||
</div>
|
||||
{permissionKind === 'specific'
|
||||
? (
|
||||
<>
|
||||
<AccessSubjectCombobox
|
||||
selectedSubjects={subjects}
|
||||
disabled={accessPolicyQuery.isLoading || accessPolicyQuery.isError || accessSubjectsQuery.isLoading}
|
||||
loading={isSaving}
|
||||
onChange={handleSubjectsChange}
|
||||
/>
|
||||
{!accessSubjectsQuery.isLoading && subjects.length === 0 && (
|
||||
<span className="mt-1.5 flex min-h-7 items-start gap-1.5 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-2 py-1.5 system-xs-regular text-util-colors-warning-warning-700">
|
||||
<span className="mt-0.5 i-ri-error-warning-line size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0">
|
||||
{t('access.members.emptySelection')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<div className="flex min-h-8 items-center system-xs-regular text-text-tertiary">
|
||||
<span className="min-w-0">
|
||||
{t(`access.permission.${permissionKind}Desc`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<SubjectsSummary
|
||||
permissionKind={permissionKind}
|
||||
subjects={subjects}
|
||||
loading={subjectsLoading}
|
||||
/>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
)
|
||||
|
||||
@ -53,7 +53,6 @@
|
||||
"access.hide": "Hide",
|
||||
"access.members.clearAll": "Clear all",
|
||||
"access.members.empty": "No matches found.",
|
||||
"access.members.emptySelection": "Choose at least one group or member to save this permission.",
|
||||
"access.members.groupCount_one": "{{count}} group",
|
||||
"access.members.groupCount_other": "{{count}} groups",
|
||||
"access.members.groups": "Groups",
|
||||
@ -63,23 +62,23 @@
|
||||
"access.members.pickPlaceholder": "Select groups or members",
|
||||
"access.members.searchPlaceholder": "Search groups and members",
|
||||
"access.members.selectedLabel": "Selected",
|
||||
"access.permission.anyone": "Anyone",
|
||||
"access.permission.anyoneDesc": "Anyone with the link, no login required",
|
||||
"access.permission.comingSoon": "Coming soon",
|
||||
"access.permission.external": "Authenticated external users",
|
||||
"access.permission.externalDesc": "External users who completed SSO/OIDC authentication",
|
||||
"access.permission.anyone": "Anyone with the link",
|
||||
"access.permission.anyoneDesc": "Anyone can access this deployment without logging in.",
|
||||
"access.permission.memberCount_one": "{{count}} member",
|
||||
"access.permission.memberCount_other": "{{count}} members",
|
||||
"access.permission.organization": "Only members within the organization",
|
||||
"access.permission.organizationDesc": "All internal members of your workspace",
|
||||
"access.permission.specific": "Specific members",
|
||||
"access.permission.specificDesc": "Pick groups or individual members",
|
||||
"access.permission.organization": "All members within the platform",
|
||||
"access.permission.organizationDesc": "All members within the platform",
|
||||
"access.permission.specific": "Specific members within the platform",
|
||||
"access.permission.specificDesc": "Select specific groups or members",
|
||||
"access.permission.specificUnavailable": "Specific member selection is disabled until real workspace subjects are connected.",
|
||||
"access.permission.updateFailed": "Failed to update access policy.",
|
||||
"access.permissions.col.environment": "Environment",
|
||||
"access.permissions.col.permission": "Access",
|
||||
"access.permissions.col.subjects": "Allowed users/groups",
|
||||
"access.permissions.description": "Configure who can access this deployment in each deployed environment.",
|
||||
"access.permissions.description": "Set access permissions for WebApp and CLI entry points in each environment.",
|
||||
"access.permissions.editAriaLabel": "Configure access for {{environment}}",
|
||||
"access.permissions.editDescription": "Set access permissions for WebApp and CLI entry points.",
|
||||
"access.permissions.editTitle": "Access permissions",
|
||||
"access.permissions.title": "Access permissions",
|
||||
"access.revoke": "Revoke",
|
||||
"access.runAccess.description": "Manage how users can run this deployment and who is allowed to access it per environment.",
|
||||
|
||||
@ -53,7 +53,6 @@
|
||||
"access.hide": "隐藏",
|
||||
"access.members.clearAll": "全部清除",
|
||||
"access.members.empty": "未找到匹配结果。",
|
||||
"access.members.emptySelection": "至少选择一个分组或成员后才会保存该权限。",
|
||||
"access.members.groupCount_one": "{{count}} 个分组",
|
||||
"access.members.groupCount_other": "{{count}} 个分组",
|
||||
"access.members.groups": "分组",
|
||||
@ -64,22 +63,22 @@
|
||||
"access.members.searchPlaceholder": "搜索分组和成员",
|
||||
"access.members.selectedLabel": "已选择",
|
||||
"access.permission.anyone": "任何人",
|
||||
"access.permission.anyoneDesc": "任何拥有链接的人,无需登录",
|
||||
"access.permission.comingSoon": "即将支持",
|
||||
"access.permission.external": "已认证的外部用户",
|
||||
"access.permission.externalDesc": "通过 SSO / OIDC 完成认证的外部用户",
|
||||
"access.permission.anyoneDesc": "任何人均可访问该部署,无需登录",
|
||||
"access.permission.memberCount_one": "{{count}} 位成员",
|
||||
"access.permission.memberCount_other": "{{count}} 位成员",
|
||||
"access.permission.organization": "仅组织内成员",
|
||||
"access.permission.organizationDesc": "工作区内所有内部成员",
|
||||
"access.permission.specific": "特定成员",
|
||||
"access.permission.specificDesc": "选择指定的分组或单个成员",
|
||||
"access.permission.organization": "平台内所有成员",
|
||||
"access.permission.organizationDesc": "平台内所有成员",
|
||||
"access.permission.specific": "平台内指定成员",
|
||||
"access.permission.specificDesc": "选择指定的分组或成员",
|
||||
"access.permission.specificUnavailable": "特定成员暂未启用,需接入真实工作区成员与分组后再开放。",
|
||||
"access.permission.updateFailed": "更新访问策略失败。",
|
||||
"access.permissions.col.environment": "环境",
|
||||
"access.permissions.col.permission": "访问范围",
|
||||
"access.permissions.col.subjects": "可访问成员/分组",
|
||||
"access.permissions.description": "配置该部署在每个已部署环境中的访问人员。",
|
||||
"access.permissions.description": "设置 WebApp 与 CLI 入口在每个环境中的访问权限。",
|
||||
"access.permissions.editAriaLabel": "配置 {{environment}} 的访问权限",
|
||||
"access.permissions.editDescription": "设置 WebApp 与 CLI 入口的访问权限。",
|
||||
"access.permissions.editTitle": "访问权限",
|
||||
"access.permissions.title": "访问权限",
|
||||
"access.revoke": "撤销",
|
||||
"access.runAccess.description": "管理用户如何运行该部署,以及在每个环境里谁可以访问。",
|
||||
|
||||
Reference in New Issue
Block a user