From e902aaa7ca5863abef6ee3fc83cc129effc217b0 Mon Sep 17 00:00:00 2001
From: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Date: Fri, 29 May 2026 18:09:30 +0800
Subject: [PATCH] Unify deployment access permission copy
---
.../access-control-dialog-content.tsx | 98 +++
.../access-control-item.tsx | 25 +-
.../add-member-or-group-pop.tsx | 367 +----------
.../app/app-access-control/index.tsx | 55 +-
.../specific-groups-or-members.tsx | 131 +---
.../__tests__/index.spec.tsx | 71 ++
.../base/access-control-option-card/index.tsx | 59 ++
.../base/access-subject-selector/index.tsx | 607 ++++++++++++++++++
.../settings-tab/access/permissions.tsx | 588 ++++++++---------
web/i18n/en-US/deployments.json | 21 +-
web/i18n/zh-Hans/deployments.json | 19 +-
11 files changed, 1175 insertions(+), 866 deletions(-)
create mode 100644 web/app/components/app/app-access-control/access-control-dialog-content.tsx
create mode 100644 web/app/components/base/access-control-option-card/__tests__/index.spec.tsx
create mode 100644 web/app/components/base/access-control-option-card/index.tsx
create mode 100644 web/app/components/base/access-subject-selector/index.tsx
diff --git a/web/app/components/app/app-access-control/access-control-dialog-content.tsx b/web/app/components/app/app-access-control/access-control-dialog-content.tsx
new file mode 100644
index 0000000000..f6b6c83ffa
--- /dev/null
+++ b/web/app/components/app/app-access-control/access-control-dialog-content.tsx
@@ -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 (
+
+
+
+ {title ?? t('accessControlDialog.title', { ns: 'app' })}
+
+
+ {description ?? t('accessControlDialog.description', { ns: 'app' })}
+
+
+
+
+
+ {accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })}
+
+
+
+
+
+
+
+ {t('accessControlDialog.accessItems.organization', { ns: 'app' })}
+
+
+
+
+
+
+
+ {!hideExternal && (
+
+
+
+
+
+ {t('accessControlDialog.accessItems.external', { ns: 'app' })}
+
+
+ {!hideExternalTip &&
}
+
+
+ )}
+
+
+
+
+ {t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/app/components/app/app-access-control/access-control-item.tsx b/web/app/components/app/app-access-control/access-control-item.tsx
index cc2cf94f0c..fae5dab23d 100644
--- a/web/app/components/app/app-access-control/access-control-item.tsx
+++ b/web/app/components/app/app-access-control/access-control-item.tsx
@@ -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 = ({ type, children }) => {
+function AccessControlItem({ type, children }: AccessControlItemProps) {
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
- if (currentMenu !== type) {
- return (
- setCurrentMenu(type)}
- >
- {children}
-
- )
- }
+ const selected = currentMenu === type
return (
- setCurrentMenu(type)}
>
{children}
-
+
)
}
diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx
index 5c09699f73..9aa6a4080f 100644
--- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx
+++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx
@@ -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(null)
- const anchorRef = useRef(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 (
-
- multiple
- open={open}
- value={selectedSubjects}
- inputValue={keyword}
- items={subjects}
- itemToStringLabel={getSubjectLabel}
- itemToStringValue={getSubjectValue}
- isItemEqualToValue={isSameSubject}
- filter={null}
- onOpenChange={handleOpenChange}
- onInputValueChange={handleInputValueChange}
- onValueChange={handleValueChange}
- >
-
-
- {t('operation.add', { ns: 'common' })}
-
-
-
-
-
-
-
-
-
- {isLoading
- ? (
-
-
-
- )
- : (
- <>
- {shouldShowBreadcrumb && (
-
-
-
- )}
- {hasResults
- ? (
- <>
-
- {(subject: Subject) => }
-
- {isFetchingNextPage &&
}
-
- >
- )
- : (
-
- {t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
-
- )}
- >
- )}
-
-
-
- )
-}
-
-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
-
- return
-}
-
-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 (
-
- {hasBreadcrumb
- ? (
-
- )
- : (
-
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
- )}
- {selectedGroupsForBreadcrumb.map((group, index) => {
- const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
-
- return (
-
- /
- {isLastGroup
- ? {group.name}
- : (
-
- )}
-
- )
- })}
-
- )
-}
-
-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 (
-
-
-
-
-
- {group.name}
- {group.groupSize}
-
-
-
-
- )
-}
-
-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 (
-
-
-
-
- {member.name}
- {currentUser.email === member.email && (
-
- (
- {t('you', { ns: 'common' })}
- )
-
- )}
-
- {member.email}
-
- )
-}
-
-type BaseItemProps = {
- className?: string
- subject: Subject
- children: React.ReactNode
-}
-function BaseItem({ children, className, subject }: BaseItemProps) {
- return (
-
- {children}
-
- )
-}
-
-function SelectionBox({ checked }: { checked: boolean }) {
- return (
-
- {checked && }
-
+ {
+ setSpecificGroups(groups)
+ setSpecificMembers(members)
+ }}
+ />
)
}
diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx
index 5a94c7e756..04ea6dbf52 100644
--- a/web/app/components/app/app-access-control/index.tsx
+++ b/web/app/components/app/app-access-control/index.tsx
@@ -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 (
-
-
- {t('accessControlDialog.title', { ns: 'app' })}
- {t('accessControlDialog.description', { ns: 'app' })}
-
-
-
-
{t('accessControlDialog.accessLabel', { ns: 'app' })}
-
-
-
-
-
-
{t('accessControlDialog.accessItems.organization', { ns: 'app' })}
-
-
-
-
-
-
-
-
-
-
-
{t('accessControlDialog.accessItems.external', { ns: 'app' })}
-
- {!hideTip &&
}
-
-
-
-
-
-
{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
-
-
-
-
-
-
-
-
+
)
}
diff --git a/web/app/components/app/app-access-control/specific-groups-or-members.tsx b/web/app/components/app/app-access-control/specific-groups-or-members.tsx
index 982a018930..249341dcba 100644
--- a/web/app/components/app/app-access-control/specific-groups-or-members.tsx
+++ b/web/app/components/app/app-access-control/specific-groups-or-members.tsx
@@ -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 (
-
+
{t('accessControlDialog.accessItems.specific', { ns: 'app' })}
@@ -39,7 +51,7 @@ export default function SpecificGroupsOrMembers() {
-
+
{t('accessControlDialog.accessItems.specific', { ns: 'app' })}
@@ -47,101 +59,20 @@ export default function SpecificGroupsOrMembers() {
-
- {isPending ? : }
-
+
{
+ setSpecificGroups(groups)
+ setSpecificMembers(members)
+ }}
+ />
)
}
-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 {t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}
- return (
- <>
- {t('accessControlDialog.groups', { ns: 'app', count: specificGroups.length ?? 0 })}
-
- {specificGroups.map((group, index) => )}
-
- {t('accessControlDialog.members', { ns: 'app', count: specificMembers.length ?? 0 })}
-
- {specificMembers.map((member, index) => )}
-
- >
- )
-}
-
-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 (
- }
- onRemove={handleRemoveGroup}
- >
- {group.name}
- {group.groupSize}
-
- )
-}
-
-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 (
- }
- onRemove={handleRemoveMember}
- >
- {member.name}
-
- )
-}
-
-type BaseItemProps = {
- icon: React.ReactNode
- children: React.ReactNode
- onRemove?: () => void
-}
-function BaseItem({ icon, onRemove, children }: BaseItemProps) {
- const { t } = useTranslation()
-
- return (
-
- )
-}
-
export function WebAppSSONotEnabledTip() {
const { t } = useTranslation()
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })
diff --git a/web/app/components/base/access-control-option-card/__tests__/index.spec.tsx b/web/app/components/base/access-control-option-card/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..804a12b90c
--- /dev/null
+++ b/web/app/components/base/access-control-option-card/__tests__/index.spec.tsx
@@ -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(
+
+ Selected access
+ ,
+ )
+
+ 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(
+
+ Selectable access
+ ,
+ )
+
+ 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(
+
+ Keyboard access
+ ,
+ )
+
+ 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(
+
+ Disabled access
+ ,
+ )
+
+ await user.click(screen.getByText('Disabled access'))
+
+ expect(onSelect).not.toHaveBeenCalled()
+ expect(screen.getByText('Disabled access').parentElement).toHaveAttribute('aria-disabled', 'true')
+ })
+})
diff --git a/web/app/components/base/access-control-option-card/index.tsx b/web/app/components/base/access-control-option-card/index.tsx
new file mode 100644
index 0000000000..cc7754e0a3
--- /dev/null
+++ b/web/app/components/base/access-control-option-card/index.tsx
@@ -0,0 +1,59 @@
+'use client'
+
+import type { ComponentPropsWithoutRef, KeyboardEvent } from 'react'
+import { cn } from '@langgenius/dify-ui/cn'
+
+type AccessControlOptionCardProps = Omit, '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) => {
+ onKeyDown?.(event)
+ if (event.defaultPrevented || !interactive)
+ return
+
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ onSelect?.()
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/web/app/components/base/access-subject-selector/index.tsx b/web/app/components/base/access-subject-selector/index.tsx
new file mode 100644
index 0000000000..52f4e9ed53
--- /dev/null
+++ b/web/app/components/base/access-subject-selector/index.tsx
@@ -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([])
+ const scrollRootRef = useRef(null)
+ const anchorRef = useRef(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 (
+
+ 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}
+ >
+
+
+
+ {t('operation.add', { ns: 'common' })}
+
+
+
+
+
+
+
+
+
+
+ {isLoading
+ ? (
+
+
+
+ )
+ : (
+ <>
+ {shouldShowBreadcrumb && (
+
+
+
+ )}
+ {hasResults
+ ? (
+ <>
+
+ {(subject: Subject) => (
+ setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])}
+ />
+ )}
+
+ {isFetchingNextPage &&
}
+
+ >
+ )
+ : (
+
+ {t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
+
+ )}
+ >
+ )}
+
+
+
+ )
+}
+
+type AccessSubjectSelectionListProps = AccessSubjectSelectionProps & {
+ loading?: boolean
+ className?: string
+}
+
+export function AccessSubjectSelectionList({
+ selectedGroups,
+ selectedMembers,
+ onChange,
+ loading,
+ className,
+}: AccessSubjectSelectionListProps) {
+ return (
+
+ {loading
+ ?
+ : (
+
+ )}
+
+ )
+}
+
+function RenderGroupsAndMembers({
+ selectedGroups,
+ selectedMembers,
+ onChange,
+}: AccessSubjectSelectionProps) {
+ const { t } = useTranslation()
+ if (selectedGroups.length <= 0 && selectedMembers.length <= 0) {
+ return (
+
+
+ {t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}
+
+
+ )
+ }
+
+ return (
+ <>
+
+ {t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })}
+
+
+ {selectedGroups.map(group => (
+
+ ))}
+
+
+ {t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })}
+
+
+ {selectedMembers.map(member => (
+
+ ))}
+
+ >
+ )
+}
+
+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 (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+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 (
+
+ {hasBreadcrumb
+ ? (
+
+ )
+ : (
+
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
+ )}
+ {selectedGroupsForBreadcrumb.map((group, index) => {
+ const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
+
+ return (
+
+ /
+ {isLastGroup
+ ? {group.name}
+ : (
+
+ )}
+
+ )
+ })}
+
+ )
+}
+
+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 (
+
+
+
+
+
+ {group.name}
+ {group.groupSize}
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+
+ {member.name}
+ {currentUser.email === member.email && (
+
+ (
+ {t('you', { ns: 'common' })}
+ )
+
+ )}
+
+ {member.email}
+
+ )
+}
+
+type ComboboxBaseItemProps = {
+ className?: string
+ subject: Subject
+ children: ReactNode
+}
+
+function ComboboxBaseItem({ children, className, subject }: ComboboxBaseItemProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+function SelectionBox({ checked }: { checked: boolean }) {
+ return (
+
+ {checked && }
+
+ )
+}
+
+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 (
+ }
+ onRemove={handleRemoveGroup}
+ >
+ {group.name}
+ {group.groupSize}
+
+ )
+}
+
+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 (
+ }
+ onRemove={handleRemoveMember}
+ >
+ {member.name}
+
+ )
+}
+
+type SelectedBaseItemProps = {
+ icon: ReactNode
+ children: ReactNode
+ onRemove?: () => void
+}
+
+function SelectedBaseItem({ icon, onRemove, children }: SelectedBaseItemProps) {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
diff --git a/web/features/deployments/detail/settings-tab/access/permissions.tsx b/web/features/deployments/detail/settings-tab/access/permissions.tsx
index 5957e594d8..a7c7c2da1c 100644
--- a/web/features/deployments/detail/settings-tab/access/permissions.tsx
+++ b/web/features/deployments/detail/settings-tab/access/permissions.tsx
@@ -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
+type AccessPolicyMode = NonNullable
type AccessSubjectType = NonNullable
-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 = {
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 (
-
-
-
- {label}
-
-
-
- {permissionOrder.map((kind) => {
- const itemIcon = permissionIcon[kind]
- const isSelected = kind === value
- return (
- onChange(kind)}
- className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2"
- >
-
-
-
-
- {t(`access.permission.${kind}`)}
-
-
-
- {t(`access.permission.${kind}Desc`)}
-
-
- {isSelected && (
-
- )}
-
- )
- })}
-
-
- )
-}
-
type SelectableAccessSubject = {
id: string
subjectType: AccessSubjectType
@@ -176,24 +114,10 @@ function normalizeSubject(subject: AccessControlSubject): SelectableAccessSubjec
}
}
-function subjectKey(subject: Pick) {
- 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 (
-
- )
+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 (
+
)
- 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 (
+
+
+ {t(`access.permission.${permissionKind}Desc`)}
+
+
+ )
}
- const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
- if (!isInteractionDisabled && details.reason !== 'item-press')
- setKeyword(inputValue)
+ if (loading) {
+ return (
+
+
+
+ )
}
- 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 (
+
+
+
+ {countLabels.length > 0 ? countLabels.join(' · ') : t('access.permission.specificDesc')}
+
+
+ )
+}
+
+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 (
-
- 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}
- >
-
-
-
- {(selectedValue: SelectableAccessSubject[]) => (
- <>
- {selectedValue.map(subject => (
-
-
- {getSubjectLabel(subject)}
- {subject.subjectType === SUBJECT_TYPE_GROUP && subject.memberCount != null && (
- {subject.memberCount}
- )}
-
-
-
-
- ))}
-
- >
- )}
-
-
-
- {loading
- ? (
-
- )
- : undefined}
-
-
-
+
- {isResultLoading
- ? (
-
- {SUBJECT_PICKER_SKELETON_KEYS.map(key => (
-
-
-
- ))}
-
- )
- : (
- <>
- {subjectsQuery.isFetching && (
-
- {t('common.loading')}
-
- )}
-
- {items.map(subject => (
-
-
-
- {getSubjectLabel(subject)}
- {subject.subjectType === SUBJECT_TYPE_GROUP && subject.memberCount != null && (
-
- {t('access.members.memberCount', { count: subject.memberCount })}
-
- )}
-
-
-
- ))}
-
- {shouldShowEmpty && (
- selectedItems.length > 0
- ? (
-
- {t('access.members.empty')}
-
- )
- : (
-
- {t('access.members.empty')}
-
- )
- )}
- >
- )}
-
-
+ onClose={onClose}
+ onConfirm={handleConfirm}
+ />
+
)
}
@@ -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({
- {environmentName(environment)}
+ {envName}
@@ -534,43 +477,34 @@ export function EnvironmentPermissionRow({
{t('access.permissions.col.permission')}
- setDialogOpen(true)}
/>
+ {dialogOpen && (
+ setDialogOpen(false)}
+ onSubmit={handlePermissionSubmit}
+ />
+ )}
{t('access.permissions.col.subjects')}
- {permissionKind === 'specific'
- ? (
- <>
-
- {!accessSubjectsQuery.isLoading && subjects.length === 0 && (
-
-
-
- {t('access.members.emptySelection')}
-
-
- )}
- >
- )
- : (
-
-
- {t(`access.permission.${permissionKind}Desc`)}
-
-
- )}
+
)
diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json
index 67bd6db05c..4dbe810b6e 100644
--- a/web/i18n/en-US/deployments.json
+++ b/web/i18n/en-US/deployments.json
@@ -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.",
diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json
index 6778f2469d..fdbc1031b9 100644
--- a/web/i18n/zh-Hans/deployments.json
+++ b/web/i18n/zh-Hans/deployments.json
@@ -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": "管理用户如何运行该部署,以及在每个环境里谁可以访问。",