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

+
+ +
+
+
+
+
+ + + + {!hideExternal && ( + +
+
+
+ {!hideExternalTip && } +
+
+ )} + +
+
+
+
+
+ + +
+
+ ) +} 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} - > - - - -
-
- - -
- {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 ( -