Unify deployment access permission copy

This commit is contained in:
Stephen Zhou
2026-05-29 18:09:30 +08:00
parent 1d4545bed1
commit e902aaa7ca
11 changed files with 1175 additions and 866 deletions

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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)
}}
/>
)
}

View File

@ -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>
)
}

View File

@ -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' })

View File

@ -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')
})
})

View 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}
/>
)
}

View 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>
)
}

View File

@ -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>
)

View File

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

View File

@ -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": "管理用户如何运行该部署,以及在每个环境里谁可以访问。",