diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e00a1c2a1a3..753aa00f187 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -458,6 +458,14 @@ "count": 1 } }, + "web/app/components/app/app-access-control/access-control-item.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/app/app-publisher/sections.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -7120,6 +7128,11 @@ "count": 1 } }, + "web/service/access-control/__tests__/index.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/service/access-control/__tests__/use-app-access-control.spec.tsx": { "no-restricted-imports": { "count": 1 @@ -7145,6 +7158,11 @@ "count": 1 } }, + "web/service/access-control/index.ts": { + "no-restricted-imports": { + "count": 1 + } + }, "web/service/access-control/use-app-access-control.ts": { "no-restricted-imports": { "count": 1 diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 764cea4fdc1..072b7446630 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -25,8 +25,6 @@ import { zAccessServiceUpdateAccessPolicyBody, zAccessServiceUpdateAccessPolicyPath, zAccessServiceUpdateAccessPolicyResponse, - zAccessSubjectServiceListAccessSubjectsQuery, - zAccessSubjectServiceListAccessSubjectsResponse, zAppInstanceServiceCreateAppInstanceBody, zAppInstanceServiceCreateAppInstanceResponse, zAppInstanceServiceDeleteAppInstancePath, @@ -109,21 +107,6 @@ import { zWebAppAuthUpdateWebAppWhitelistSubjectsResponse, } from './zod.gen' -export const listAccessSubjects = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'AccessSubjectService_ListAccessSubjects', - path: '/enterprise/access-subjects', - tags: ['AccessSubjectService'], - }) - .input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() })) - .output(zAccessSubjectServiceListAccessSubjectsResponse) - -export const accessSubjectService = { - listAccessSubjects, -} - export const listAppInstanceSummaries = oc .route({ inputStructure: 'detailed', @@ -730,7 +713,6 @@ export const webAppAuth = { } export const contract = { - accessSubjectService, appInstanceService, accessService, deploymentService, diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index 882a85465b9..164e23a6e17 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -1038,6 +1038,7 @@ export type UpdateEnvironmentRequest = { environmentId?: string displayName: string description?: string + cpuCount?: number } export type UpdateEnvironmentResponse = { @@ -1367,6 +1368,7 @@ export type InfoConfigReply = { Branding?: BrandingInfo WebAppAuth?: WebAppAuthInfo PluginInstallationPermission?: PluginInstallationPermissionInfo + EnableAppDeploy?: boolean } export type InnerAdmission = { @@ -1513,11 +1515,6 @@ export type LimitFields = { appRunnerEnvCpus?: ResourceQuota } -export type ListAccessSubjectsReply = { - subjects?: Array - pagination?: Pagination -} - export type ListGroupAppsResponse = { items?: Array total?: string @@ -2138,25 +2135,6 @@ export type Pagination = { totalPages?: number } -export type AccessSubjectServiceListAccessSubjectsData = { - body?: never - path?: never - query?: { - keyword?: string - groupId?: string - pageNumber?: number - resultsPerPage?: number - } - url: '/enterprise/access-subjects' -} - -export type AccessSubjectServiceListAccessSubjectsResponses = { - 200: ListAccessSubjectsReply -} - -export type AccessSubjectServiceListAccessSubjectsResponse - = AccessSubjectServiceListAccessSubjectsResponses[keyof AccessSubjectServiceListAccessSubjectsResponses] - export type AppInstanceServiceListAppInstanceSummariesData = { body?: never path?: never diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index 85f74b22121..4bce9b2f633 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -912,6 +912,7 @@ export const zUpdateEnvironmentRequest = z.object({ environmentId: z.string().optional(), displayName: z.string(), description: z.string().optional(), + cpuCount: z.number().optional(), }) export const zUpdateEnvironmentResponse = z.object({ @@ -2072,6 +2073,7 @@ export const zInfoConfigReply = z.object({ Branding: zBrandingInfo.optional(), WebAppAuth: zWebAppAuthInfo.optional(), PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(), + EnableAppDeploy: z.boolean().optional(), }) export const zWebOAuth2LoginReply = z.object({ @@ -2243,11 +2245,6 @@ export const zListRollbackTargetsResponse = z.object({ pagination: zPagination, }) -export const zListAccessSubjectsReply = z.object({ - subjects: z.array(zSubject).optional(), - pagination: zPagination.optional(), -}) - export const zListMembersReply = z.object({ data: z.array(zAccountDetail).optional(), pagination: zPagination.optional(), @@ -2268,26 +2265,6 @@ export const zListWorkspacesReply = z.object({ pagination: zPagination.optional(), }) -export const zAccessSubjectServiceListAccessSubjectsQuery = z.object({ - keyword: z.string().optional(), - groupId: z.string().optional(), - pageNumber: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - resultsPerPage: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -/** - * OK - */ -export const zAccessSubjectServiceListAccessSubjectsResponse = zListAccessSubjectsReply - export const zAppInstanceServiceListAppInstanceSummariesQuery = z.object({ pageNumber: z .int() diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx index 4ecfde0158c..c15c927fa90 100644 --- a/web/__tests__/app/app-access-control-flow.test.tsx +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -89,7 +89,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () })) vi.mock('@/app/components/app/app-access-control', () => ({ - AccessControl: ({ + default: ({ onConfirm, onClose, }: { diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index 37019fba17e..cf7ca7e19bb 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -112,9 +112,14 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () }, })) -vi.mock('@/app/components/app/app-access-control', () => ({ - AccessControl: () =>
, -})) +vi.mock('@/app/components/app/app-access-control', () => { + const MockAccessControl = () =>
+ + return { + default: MockAccessControl, + AccessControl: MockAccessControl, + } +}) vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 1c8624ddde9..ec0156fc563 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -212,14 +212,19 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ ), })) -vi.mock('@/app/components/app/app-access-control', () => ({ - AccessControl: ({ onConfirm, onClose }: Record) => ( +vi.mock('@/app/components/app/app-access-control', () => { + const MockAccessControl = ({ onConfirm, onClose }: Record) => (
- ), -})) + ) + + return { + default: MockAccessControl, + AccessControl: MockAccessControl, + } +}) const createMockApp = (overrides: Partial = {}): App => ({ id: overrides.id ?? 'app-1', diff --git a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx index acbdf1281e5..03a35bd52ab 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { AccessControlDialog } from '../access-control-dialog' +import AccessControlDialog from '../access-control-dialog' describe('AccessControlDialog', () => { it('should render dialog content when visible', () => { diff --git a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx index 0972a654751..b1a862a13c9 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx @@ -1,43 +1,45 @@ import { fireEvent, render, screen } from '@testing-library/react' +import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' -import { AccessControlItem } from '../access-control-item' -import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness' -import { createAccessControlDraftHarness } from './access-control-test-utils' +import AccessControlItem from '../access-control-item' describe('AccessControlItem', () => { beforeEach(() => { vi.clearAllMocks() + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.PUBLIC, + selectedGroupsForBreadcrumb: [], + }) }) it('should update current menu when selecting a different access type', () => { - const harness = createAccessControlDraftHarness( - - - Organization Only - - , - { currentMenu: AccessMode.PUBLIC }, + render( + + Organization Only + , ) - render(harness.element) - const option = screen.getByRole('radio', { name: 'Organization Only' }) + const option = screen.getByText('Organization Only').parentElement as HTMLElement fireEvent.click(option) - expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION) + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) }) it('should keep the selected state for the active access type', () => { - const harness = createAccessControlDraftHarness( - - - Organization Only - - , - { currentMenu: AccessMode.ORGANIZATION }, - ) - render(harness.element) + useAccessControlStore.setState({ + currentMenu: AccessMode.ORGANIZATION, + }) - const option = screen.getByRole('radio', { name: 'Organization Only' }) - expect(option).toHaveAttribute('data-checked') + render( + + Organization Only + , + ) + + const option = screen.getByText('Organization Only').parentElement as HTMLElement + expect(option).toHaveClass('border-components-option-card-option-selected-border') }) }) diff --git a/web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx b/web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx deleted file mode 100644 index 8dc6cbd75cb..00000000000 --- a/web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { ReactNode } from 'react' -import type { AccessMode } from '@/models/access-control' -import { RadioGroup } from '@langgenius/dify-ui/radio-group' -import { useAccessControlStore } from '../store' - -export function AccessControlRadioGroupHarness({ children }: { - children: ReactNode -}) { - const currentMenu = useAccessControlStore(state => state.currentMenu) - const setCurrentMenu = useAccessControlStore(state => state.setCurrentMenu) - - return ( - value={currentMenu} onValueChange={setCurrentMenu}> - {children} - - ) -} diff --git a/web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts b/web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts deleted file mode 100644 index 2a6f27b8331..00000000000 --- a/web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ReactNode } from 'react' -import type { AccessControlDraft, AccessControlStore } from '../store' -import { createElement } from 'react' -import { AccessMode } from '@/models/access-control' -import { useAccessControlStore } from '../store' -import { AccessControlDraftProvider } from '../store-provider' - -const emptyDraft = { - appId: '', - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - specificGroups: [], - specificMembers: [], - selectedGroupsForBreadcrumb: [], -} satisfies Required - -function draftKey(draft: AccessControlDraft) { - return [ - draft.appId ?? '', - draft.currentMenu, - draft.specificGroups?.map(group => group.id).join(',') ?? '', - draft.specificMembers?.map(member => member.id).join(',') ?? '', - draft.selectedGroupsForBreadcrumb?.map(group => group.id).join(',') ?? '', - ].join(':') -} - -function completeDraft(initialDraft: Partial = {}): Required { - return { - ...emptyDraft, - ...initialDraft, - } -} - -function SnapshotProbe({ onSnapshot }: { - onSnapshot: (snapshot: AccessControlStore) => void -}) { - onSnapshot(useAccessControlStore(state => state)) - return null -} - -export function createAccessControlDraftHarness( - children: ReactNode, - initialDraft?: Partial, -) { - const draft = completeDraft(initialDraft) - let snapshot: AccessControlStore = { - appId: draft.appId, - specificGroups: draft.specificGroups, - setSpecificGroups: () => undefined, - specificMembers: draft.specificMembers, - setSpecificMembers: () => undefined, - currentMenu: draft.currentMenu, - setCurrentMenu: () => undefined, - selectedGroupsForBreadcrumb: draft.selectedGroupsForBreadcrumb, - setSelectedGroupsForBreadcrumb: () => undefined, - } - - return { - element: createElement( - AccessControlDraftProvider, - { - draftKey: draftKey(draft), - initialDraft: draft, - }, - createElement(SnapshotProbe, { - onSnapshot: nextSnapshot => snapshot = nextSnapshot, - }), - children, - ), - getSnapshot: () => snapshot, - } -} diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 7cfa29fe97a..52c2a0dd543 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -1,23 +1,24 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import type { App } from '@/types/app' -import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' +import useAccessControlStore from '@/context/access-control-store' import { AccessMode, SubjectType } from '@/models/access-control' -import { AccessControlDialog } from '../access-control-dialog' -import { AccessControlItem } from '../access-control-item' -import { AddMemberOrGroupDialog } from '../add-member-or-group-pop' -import { AccessControl } from '../index' -import { SpecificGroupsOrMembers } from '../specific-groups-or-members' -import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness' -import { createAccessControlDraftHarness } from './access-control-test-utils' +import AccessControlDialog from '../access-control-dialog' +import AccessControlItem from '../access-control-item' +import AddMemberOrGroupDialog from '../add-member-or-group-pop' +import AccessControl from '../index' +import SpecificGroupsOrMembers from '../specific-groups-or-members' const mockUseAppWhiteListSubjects = vi.fn() const mockUseSearchForWhiteListCandidates = vi.fn() -const mockMutate = vi.fn() -const mockUseMutation = vi.hoisted(() => vi.fn()) +const mockMutateAsync = vi.fn() +const mockUseUpdateAccessMode = vi.fn(() => ({ + isPending: false, + mutateAsync: mockMutateAsync, +})) const intersectionObserverMocks = vi.hoisted(() => ({ callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void), })) @@ -35,19 +36,12 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/service/access-control/use-app-access-control', () => ({ +vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), + useUpdateAccessMode: () => mockUseUpdateAccessMode(), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useMutation: (...args: unknown[]) => mockUseMutation(...args), - } -}) - vi.mock('ahooks', async (importOriginal) => { const actual = await importOriginal() return { @@ -100,12 +94,10 @@ beforeAll(() => { }) beforeEach(() => { - mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }) - mockUseMutation.mockReturnValue({ + mockMutateAsync.mockResolvedValue(undefined) + mockUseUpdateAccessMode.mockReturnValue({ isPending: false, - mutate: mockMutate, + mutateAsync: mockMutateAsync, }) mockUseAppWhiteListSubjects.mockReturnValue({ isPending: false, @@ -125,39 +117,33 @@ beforeEach(() => { // AccessControlItem handles selected vs. unselected styling and click state updates describe('AccessControlItem', () => { it('should update current menu when selecting a different access type', () => { - const harness = createAccessControlDraftHarness( - - - Organization Only - - , - { currentMenu: AccessMode.PUBLIC }, + useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC }) + render( + + Organization Only + , ) - render(harness.element) - const option = screen.getByRole('radio', { name: 'Organization Only' }) + const option = screen.getByText('Organization Only').parentElement as HTMLElement expect(option).toHaveClass('cursor-pointer') fireEvent.click(option) - expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION) + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) }) it('should keep current menu when clicking the selected access type', () => { - const harness = createAccessControlDraftHarness( - - - Organization Only - - , - { currentMenu: AccessMode.ORGANIZATION }, + useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION }) + render( + + Organization Only + , ) - render(harness.element) - const option = screen.getByRole('radio', { name: 'Organization Only' }) + const option = screen.getByText('Organization Only').parentElement as HTMLElement fireEvent.click(option) - expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION) + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) }) }) @@ -194,40 +180,32 @@ describe('AccessControlDialog', () => { // SpecificGroupsOrMembers syncs store state with fetched data and supports removals describe('SpecificGroupsOrMembers', () => { it('should render collapsed view when not in specific selection mode', () => { - const harness = createAccessControlDraftHarness( - , - { currentMenu: AccessMode.ORGANIZATION }, - ) + useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION }) - render(harness.element) + render() expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument() }) it('should show loading state while pending', async () => { - const harness = createAccessControlDraftHarness( - , - { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - ) + useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: true, + data: undefined, + }) - render(harness.element) + const { container } = render() - expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument() + await waitFor(() => { + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) }) it('should render fetched groups and members and support removal', async () => { - const harness = createAccessControlDraftHarness( - , - { - appId: 'app-1', - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - specificGroups: [baseGroup], - specificMembers: [baseMember], - }, - ) + useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) - render(harness.element) + render() await waitFor(() => { expect(screen.getByText(baseGroup.name)).toBeInTheDocument() @@ -256,12 +234,8 @@ describe('SpecificGroupsOrMembers', () => { describe('AddMemberOrGroupDialog', () => { it('should open search popover and display candidates', async () => { const user = userEvent.setup() - const harness = createAccessControlDraftHarness( - , - { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - ) - render(harness.element) + render() await user.click(screen.getByText('common.operation.add')) @@ -272,21 +246,17 @@ describe('AddMemberOrGroupDialog', () => { it('should allow selecting members and expanding groups', async () => { const user = userEvent.setup() - const harness = createAccessControlDraftHarness( - , - { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - ) - render(harness.element) + render() await user.click(screen.getByText('common.operation.add')) const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand') await user.click(expandButton) - expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) await user.click(screen.getByRole('option', { name: /Member One/ })) - expect(harness.getSnapshot().specificMembers).toEqual([baseMember]) + expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) }) it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => { @@ -299,11 +269,7 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - const harness = createAccessControlDraftHarness( - , - { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - ) - render(harness.element) + render() await user.click(screen.getByText('common.operation.add')) await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group') @@ -320,9 +286,9 @@ describe('AddMemberOrGroupDialog', () => { fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers')) - expect(harness.getSnapshot().specificGroups).toEqual([]) - expect(harness.getSnapshot().specificMembers).toEqual([]) - expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([]) + expect(useAccessControlStore.getState().specificGroups).toEqual([]) + expect(useAccessControlStore.getState().specificMembers).toEqual([]) + expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) expect(fetchNextPage).not.toHaveBeenCalled() }) @@ -335,11 +301,7 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - const harness = createAccessControlDraftHarness( - , - { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - ) - render(harness.element) + render() await user.click(screen.getByText('common.operation.add')) @@ -353,6 +315,10 @@ describe('AccessControl', () => { const onClose = vi.fn() const onConfirm = vi.fn() const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') + useAccessControlStore.setState({ + specificGroups: [baseGroup], + specificMembers: [baseMember], + }) const app = { id: 'app-id-1', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, @@ -366,22 +332,21 @@ describe('AccessControl', () => { />, ) + await waitFor(() => { + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS) + }) + fireEvent.click(screen.getByText('common.operation.confirm')) await waitFor(() => { - expect(mockMutate).toHaveBeenCalledWith( - { - body: { - appId: app.id, - accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - subjects: [ - { subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP }, - { subjectId: baseMember.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT }, - ], - }, - }, - expect.objectContaining({ onSuccess: expect.any(Function) }), - ) + expect(mockMutateAsync).toHaveBeenCalledWith({ + appId: app.id, + accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + subjects: [ + { subjectId: baseGroup.id, subjectType: SubjectType.GROUP }, + { subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT }, + ], + }) expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess') expect(onConfirm).toHaveBeenCalled() }) diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx index 11ba79e1851..d34756e85e4 100644 --- a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -1,9 +1,9 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { AccessMode, SubjectType } from '@/models/access-control' -import { AddMemberOrGroupDialog } from '../add-member-or-group-pop' -import { createAccessControlDraftHarness } from './access-control-test-utils' +import useAccessControlStore from '@/context/access-control-store' +import { SubjectType } from '@/models/access-control' +import AddMemberOrGroupDialog from '../add-member-or-group-pop' const mockUseSearchForWhiteListCandidates = vi.fn() const intersectionObserverMocks = vi.hoisted(() => ({ @@ -18,7 +18,7 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/service/access-control/use-app-access-control', () => ({ +vi.mock('@/service/access-control', () => ({ useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), })) @@ -69,6 +69,13 @@ describe('AddMemberOrGroupDialog', () => { beforeEach(() => { vi.clearAllMocks() + useAccessControlStore.setState({ + appId: 'app-1', + specificGroups: [], + specificMembers: [], + currentMenu: SubjectType.GROUP as never, + selectedGroupsForBreadcrumb: [], + }) mockUseSearchForWhiteListCandidates.mockReturnValue({ isLoading: false, isFetchingNextPage: false, @@ -81,11 +88,7 @@ describe('AddMemberOrGroupDialog', () => { it('should open the search popover and display candidates', async () => { const user = userEvent.setup() - const harness = createAccessControlDraftHarness( - , - { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - ) - render(harness.element) + render() await user.click(screen.getByText('common.operation.add')) @@ -96,20 +99,16 @@ describe('AddMemberOrGroupDialog', () => { it('should allow expanding groups and selecting members', async () => { const user = userEvent.setup() - const harness = createAccessControlDraftHarness( - , - { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - ) - render(harness.element) + render() await user.click(screen.getByText('common.operation.add')) await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) - expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) await user.click(screen.getByRole('option', { name: /Member One/ })) - expect(harness.getSnapshot().specificMembers).toEqual([baseMember]) + expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) }) it('should show the empty state when no candidates are returned', async () => { @@ -121,11 +120,7 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - const harness = createAccessControlDraftHarness( - , - { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - ) - render(harness.element) + render() await user.click(screen.getByText('common.operation.add')) @@ -133,6 +128,9 @@ describe('AddMemberOrGroupDialog', () => { }) it('should keep breadcrumbs visible when the current group has no candidates', async () => { + useAccessControlStore.setState({ + selectedGroupsForBreadcrumb: [baseGroup], + }) mockUseSearchForWhiteListCandidates.mockReturnValue({ isLoading: false, isFetchingNextPage: false, @@ -141,15 +139,7 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - const harness = createAccessControlDraftHarness( - , - { - appId: 'app-1', - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - selectedGroupsForBreadcrumb: [baseGroup], - }, - ) - render(harness.element) + render() await user.click(screen.getByText('common.operation.add')) @@ -159,6 +149,6 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })) - expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([]) + expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) }) }) diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index dd5e2f4f22f..74e7d7046c0 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -3,8 +3,9 @@ import type { App } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' -import { AccessControl } from '../index' +import AccessControl from '../index' let mockWebappAuth = { enabled: true, @@ -17,24 +18,20 @@ const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { systemFeatures: { webapp_auth: mockWebappAuth }, }) -const mockMutate = vi.fn() -const mockUseMutation = vi.hoisted(() => vi.fn()) +const mockMutateAsync = vi.fn() +const mockUseUpdateAccessMode = vi.fn(() => ({ + isPending: false, + mutateAsync: mockMutateAsync, +})) const mockUseAppWhiteListSubjects = vi.fn() const mockUseSearchForWhiteListCandidates = vi.fn() -vi.mock('@/service/access-control/use-app-access-control', () => ({ +vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), + useUpdateAccessMode: () => mockUseUpdateAccessMode(), })) -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useMutation: (...args: unknown[]) => mockUseMutation(...args), - } -}) - describe('AccessControl', () => { beforeEach(() => { vi.clearAllMocks() @@ -44,13 +41,14 @@ describe('AccessControl', () => { allow_email_password_login: false, allow_email_code_login: false, } - mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => { - options?.onSuccess?.() - }) - mockUseMutation.mockReturnValue({ - isPending: false, - mutate: mockMutate, + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + selectedGroupsForBreadcrumb: [], }) + mockMutateAsync.mockResolvedValue(undefined) mockUseAppWhiteListSubjects.mockReturnValue({ isPending: false, data: { @@ -83,18 +81,18 @@ describe('AccessControl', () => { />, ) + await waitFor(() => { + expect(useAccessControlStore.getState().appId).toBe(app.id) + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC) + }) + fireEvent.click(screen.getByText('common.operation.confirm')) await waitFor(() => { - expect(mockMutate).toHaveBeenCalledWith( - { - body: { - appId: app.id, - accessMode: AccessMode.PUBLIC, - }, - }, - expect.objectContaining({ onSuccess: expect.any(Function) }), - ) + expect(mockMutateAsync).toHaveBeenCalledWith({ + appId: app.id, + accessMode: AccessMode.PUBLIC, + }) expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess') expect(onConfirm).toHaveBeenCalledTimes(1) }) @@ -118,30 +116,4 @@ describe('AccessControl', () => { expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument() expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument() }) - - it('should prevent confirming specific access before subjects are loaded', () => { - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: true, - data: undefined, - }) - - render( - , - ) - - const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) - const organizationOption = screen.getByRole('radio', { - name: 'app.accessControlDialog.accessItems.organization', - }) - - expect(confirmButton).toBeDisabled() - expect(organizationOption).toHaveAttribute('aria-disabled', 'true') - - fireEvent.click(confirmButton) - - expect(mockMutate).not.toHaveBeenCalled() - }) }) diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx index 5e8ec42be8a..e7635219405 100644 --- a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -1,17 +1,17 @@ import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' -import { SpecificGroupsOrMembers } from '../specific-groups-or-members' -import { createAccessControlDraftHarness } from './access-control-test-utils' +import SpecificGroupsOrMembers from '../specific-groups-or-members' -const mockUseSearchForWhiteListCandidates = vi.fn() +const mockUseAppWhiteListSubjects = vi.fn() vi.mock('@/service/access-control', () => ({ - useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), + useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), })) -vi.mock('@/service/access-control/use-app-access-control', () => ({ - useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), +vi.mock('../add-member-or-group-pop', () => ({ + default: () =>
, })) const createGroup = (overrides: Partial = {}): AccessControlGroup => ({ @@ -36,48 +36,50 @@ describe('SpecificGroupsOrMembers', () => { beforeEach(() => { vi.clearAllMocks() - mockUseSearchForWhiteListCandidates.mockReturnValue({ - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - data: { pages: [] }, + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + selectedGroupsForBreadcrumb: [], + }) + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: false, + data: { + groups: [baseGroup], + members: [baseMember], + }, }) }) it('should render the collapsed row when not in specific mode', () => { - const harness = createAccessControlDraftHarness( - , - { currentMenu: AccessMode.ORGANIZATION }, - ) + useAccessControlStore.setState({ + currentMenu: AccessMode.ORGANIZATION, + }) - render(harness.element) + render() expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'common.operation.add' })).not.toBeInTheDocument() + expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() }) - it('should show loading when the selected subjects are pending', async () => { - const harness = createAccessControlDraftHarness() - render(harness.element) + it('should show loading while whitelist subjects are pending', async () => { + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: true, + data: undefined, + }) - expect(screen.getByRole('combobox', { name: 'common.operation.add' })).toBeDisabled() + const { container } = render() await waitFor(() => { - expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument() + expect(container.querySelector('.spin-animation')).toBeInTheDocument() }) }) it('should render fetched groups and members and support removal', async () => { - const harness = createAccessControlDraftHarness( - , - { - appId: 'app-1', - specificGroups: [baseGroup], - specificMembers: [baseMember], - }, - ) + useAccessControlStore.setState({ appId: 'app-1' }) - render(harness.element) + render() await waitFor(() => { expect(screen.getByText(baseGroup.name)).toBeInTheDocument() @@ -89,9 +91,9 @@ describe('SpecificGroupsOrMembers', () => { const memberRemove = removeButtons[1]! fireEvent.click(groupRemove) - expect(harness.getSnapshot().specificGroups).toEqual([]) + expect(useAccessControlStore.getState().specificGroups).toEqual([]) fireEvent.click(memberRemove) - expect(harness.getSnapshot().specificMembers).toEqual([]) + expect(useAccessControlStore.getState().specificMembers).toEqual([]) }) }) diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx index c29e73aa9a4..a863935f90d 100644 --- a/web/app/components/app/app-access-control/access-control-dialog.tsx +++ b/web/app/components/app/app-access-control/access-control-dialog.tsx @@ -5,6 +5,7 @@ import { DialogCloseButton, DialogContent, } from '@langgenius/dify-ui/dialog' +import { useCallback } from 'react' type DialogProps = { className?: string @@ -13,17 +14,21 @@ type DialogProps = { onClose?: () => void } -export function AccessControlDialog({ +const AccessControlDialog = ({ className, children, show, onClose, -}: DialogProps) { +}: DialogProps) => { + const close = useCallback(() => { + onClose?.() + }, [onClose]) + return ( - !open && onClose?.()}> + !open && close()}> @@ -33,3 +38,5 @@ export function AccessControlDialog({ ) } + +export default AccessControlDialog 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 5913a7e47f8..cc2cf94f0c4 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,26 +1,37 @@ 'use client' -import type { PropsWithChildren } from 'react' +import type { FC, PropsWithChildren } from 'react' import type { AccessMode } from '@/models/access-control' -import { cn } from '@langgenius/dify-ui/cn' -import { RadioRoot } from '@langgenius/dify-ui/radio' +import useAccessControlStore from '@/context/access-control-store' -export function AccessControlItem({ type, children }: PropsWithChildren<{ +type AccessControlItemProps = PropsWithChildren<{ type: AccessMode -}>) { +}> + +const AccessControlItem: FC = ({ type, children }) => { + const currentMenu = useAccessControlStore(s => s.currentMenu) + const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu) + if (currentMenu !== type) { + return ( +
setCurrentMenu(type)} + > + {children} +
+ ) + } + return ( - - value={type} - variant="unstyled" - render={
} - className={cn( - 'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors', - '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', - 'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset', - 'data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg', - )} +
{children} - +
) } + +AccessControlItem.displayName = 'AccessControlItem' + +export default AccessControlItem 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 c1df04d257c..13ff5a520b4 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,31 +1,371 @@ '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 { - AccessSubjectAddButton, -} from './access-subject-selector/add-button' -import { useAccessControlStore } from './store' + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxItemText, + ComboboxList, + ComboboxStatus, + ComboboxTrigger, +} from '@langgenius/dify-ui/combobox' +import { 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' +import useAccessControlStore from '../../../../context/access-control-store' +import Loading from '../../base/loading' -export function AddMemberOrGroupDialog({ disabled = false }: { - disabled?: boolean -}) { +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 setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb) + 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 ( - { - setSpecificGroups(groups) - setSpecificMembers(members) - }} - /> + + 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 ( +