mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
fix: decouple deployment access control dialog (#37936)
This commit is contained in:
parent
1c5e1280cb
commit
f4e832f35c
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<Subject>
|
||||
pagination?: Pagination
|
||||
}
|
||||
|
||||
export type ListGroupAppsResponse = {
|
||||
items?: Array<GroupAppItem>
|
||||
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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
}: {
|
||||
|
||||
@ -112,9 +112,14 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
AccessControl: () => <div data-testid="app-access-control" />,
|
||||
}))
|
||||
vi.mock('@/app/components/app/app-access-control', () => {
|
||||
const MockAccessControl = () => <div data-testid="app-access-control" />
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
|
||||
@ -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<string, unknown>) => (
|
||||
vi.mock('@/app/components/app/app-access-control', () => {
|
||||
const MockAccessControl = ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
<div data-testid="access-control-modal">
|
||||
<button data-testid="confirm-access" onClick={onConfirm as () => void}>Confirm</button>
|
||||
<button data-testid="cancel-access" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.PUBLIC },
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
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(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ 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(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
expect(option).toHaveClass('border-components-option-card-option-selected-border')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<RadioGroup<AccessMode> value={currentMenu} onValueChange={setCurrentMenu}>
|
||||
{children}
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
@ -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<AccessControlDraft>
|
||||
|
||||
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<AccessControlDraft> = {}): Required<AccessControlDraft> {
|
||||
return {
|
||||
...emptyDraft,
|
||||
...initialDraft,
|
||||
}
|
||||
}
|
||||
|
||||
function SnapshotProbe({ onSnapshot }: {
|
||||
onSnapshot: (snapshot: AccessControlStore) => void
|
||||
}) {
|
||||
onSnapshot(useAccessControlStore(state => state))
|
||||
return null
|
||||
}
|
||||
|
||||
export function createAccessControlDraftHarness(
|
||||
children: ReactNode,
|
||||
initialDraft?: Partial<AccessControlDraft>,
|
||||
) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -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<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useMutation: (...args: unknown[]) => mockUseMutation(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
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(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.PUBLIC },
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
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(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
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(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
)
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
|
||||
render(harness.element)
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
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(
|
||||
<SpecificGroupsOrMembers loading />,
|
||||
{ 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(<SpecificGroupsOrMembers />)
|
||||
|
||||
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(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{
|
||||
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(<SpecificGroupsOrMembers />)
|
||||
|
||||
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(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
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(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
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(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
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(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
@ -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(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
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(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
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(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
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(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{
|
||||
appId: 'app-1',
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
selectedGroupsForBreadcrumb: [baseGroup],
|
||||
},
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<typeof import('@tanstack/react-query')>()
|
||||
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(
|
||||
<AccessControl
|
||||
app={{ id: 'app-id-3', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: () => <div data-testid="add-member-or-group-dialog" />,
|
||||
}))
|
||||
|
||||
const createGroup = (overrides: Partial<AccessControlGroup> = {}): 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(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
)
|
||||
useAccessControlStore.setState({
|
||||
currentMenu: AccessMode.ORGANIZATION,
|
||||
})
|
||||
|
||||
render(harness.element)
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
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(<SpecificGroupsOrMembers loading />)
|
||||
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(<SpecificGroupsOrMembers />)
|
||||
|
||||
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(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{
|
||||
appId: 'app-1',
|
||||
specificGroups: [baseGroup],
|
||||
specificMembers: [baseMember],
|
||||
},
|
||||
)
|
||||
useAccessControlStore.setState({ appId: 'app-1' })
|
||||
|
||||
render(harness.element)
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && onClose?.()}>
|
||||
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && close()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
|
||||
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-all',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@ -33,3 +38,5 @@ export function AccessControlDialog({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessControlDialog
|
||||
|
||||
@ -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<AccessControlItemProps> = ({ type, children }) => {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
if (currentMenu !== type) {
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer rounded-[10px] border
|
||||
border-components-option-card-option-border bg-components-option-card-option-bg
|
||||
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
|
||||
onClick={() => setCurrentMenu(type)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioRoot<AccessMode>
|
||||
value={type}
|
||||
variant="unstyled"
|
||||
render={<div />}
|
||||
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',
|
||||
)}
|
||||
<div className="rounded-[10px] border-[1.5px]
|
||||
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</RadioRoot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
AccessControlItem.displayName = 'AccessControlItem'
|
||||
|
||||
export default AccessControlItem
|
||||
|
||||
@ -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<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const 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 (
|
||||
<AccessSubjectAddButton
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
disabled={disabled}
|
||||
breadcrumbGroups={selectedGroupsForBreadcrumb}
|
||||
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
<Combobox<Subject, true>
|
||||
multiple
|
||||
open={open}
|
||||
value={selectedSubjects}
|
||||
inputValue={keyword}
|
||||
items={subjects}
|
||||
itemToStringLabel={getSubjectLabel}
|
||||
itemToStringValue={getSubjectValue}
|
||||
isItemEqualToValue={isSameSubject}
|
||||
filter={null}
|
||||
onOpenChange={handleOpenChange}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
aria-label={t('operation.add', { ns: 'common' })}
|
||||
icon={false}
|
||||
size="small"
|
||||
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
|
||||
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0">{t('operation.add', { ns: 'common' })}</span>
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="bottom-end"
|
||||
alignOffset={300}
|
||||
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<ComboboxInput
|
||||
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
{isLoading
|
||||
? (
|
||||
<ComboboxStatus className="p-1">
|
||||
<Loading />
|
||||
</ComboboxStatus>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{shouldShowBreadcrumb && (
|
||||
<div className="flex h-7 items-center px-2 py-0.5">
|
||||
<SelectedGroupsBreadCrumb />
|
||||
</div>
|
||||
)}
|
||||
{hasResults
|
||||
? (
|
||||
<>
|
||||
<ComboboxList className="max-h-none p-1">
|
||||
{(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
|
||||
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
|
||||
</ComboboxEmpty>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
function groupToSubject(group: AccessControlGroup): SubjectGroup {
|
||||
return {
|
||||
subjectId: group.id,
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: group,
|
||||
}
|
||||
}
|
||||
|
||||
function memberToSubject(member: AccessControlAccount): SubjectAccount {
|
||||
return {
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: member,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubjectLabel(subject: Subject) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return (subject as SubjectGroup).groupData.name
|
||||
|
||||
return (subject as SubjectAccount).accountData.name
|
||||
}
|
||||
|
||||
function getSubjectValue(subject: Subject) {
|
||||
return `${subject.subjectType}:${subject.subjectId}`
|
||||
}
|
||||
|
||||
function isSameSubject(item: Subject, value: Subject) {
|
||||
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
|
||||
}
|
||||
|
||||
function SubjectItem({ subject }: { subject: Subject }) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} />
|
||||
|
||||
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
|
||||
}
|
||||
|
||||
function SelectedGroupsBreadCrumb() {
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleBreadCrumbClick = (index: number) => {
|
||||
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||
setSelectedGroupsForBreadcrumb(newGroups)
|
||||
}
|
||||
const handleReset = () => {
|
||||
setSelectedGroupsForBreadcrumb([])
|
||||
}
|
||||
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
|
||||
{hasBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
|
||||
)}
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
|
||||
<span>/</span>
|
||||
{isLastGroup
|
||||
? <span>{group.name}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={() => handleBreadCrumbClick(index)}
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
subject: Subject
|
||||
}
|
||||
function GroupItem({ group, subject }: GroupItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const isChecked = specificGroups.some(g => g.id === group.id)
|
||||
|
||||
const handleExpandClick = () => {
|
||||
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
|
||||
<BaseItem subject={subject}>
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
|
||||
</ComboboxItemText>
|
||||
</BaseItem>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isChecked}
|
||||
variant="ghost-accent"
|
||||
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
|
||||
onPointerDown={event => event.preventDefault()}
|
||||
onClick={handleExpandClick}
|
||||
>
|
||||
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
|
||||
<RiArrowRightSLine className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
subject: Subject
|
||||
}
|
||||
function MemberItem({ member, subject }: MemberItemProps) {
|
||||
const currentUser = useSelector(s => s.userProfile)
|
||||
const { t } = useTranslation()
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const isChecked = specificMembers.some(m => m.id === member.id)
|
||||
return (
|
||||
<BaseItem subject={subject} className="pr-3">
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<Avatar size="xxs" avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
|
||||
{currentUser.email === member.email && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
(
|
||||
{t('you', { ns: 'common' })}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
className?: string
|
||||
subject: Subject
|
||||
children: React.ReactNode
|
||||
}
|
||||
function BaseItem({ children, className, subject }: BaseItemProps) {
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={subject}
|
||||
className={cn(
|
||||
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ComboboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionBox({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
|
||||
checked
|
||||
? 'bg-components-checkbox-bg text-components-checkbox-icon'
|
||||
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
|
||||
)}
|
||||
>
|
||||
{checked && <span className="i-ri-check-line size-3" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
'use client'
|
||||
import type { Subject as EnterpriseSubject } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AccessControlDialog } from './access-control-dialog'
|
||||
import { AccessControlDialogContent } from './access-control-dialog-content'
|
||||
import { useAccessControlStore } from './store'
|
||||
import { AccessControlDraftProvider } from './store-provider'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { useUpdateAccessMode } from '@/service/access-control'
|
||||
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'
|
||||
|
||||
type AccessControlProps = {
|
||||
app: App
|
||||
@ -20,113 +22,92 @@ type AccessControlProps = {
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export function AccessControl(props: AccessControlProps) {
|
||||
export default function AccessControl(props: AccessControlProps) {
|
||||
const { app, onClose, onConfirm } = props
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
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)
|
||||
const initialAccessMode = app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
const whiteListSubjectsQuery = useAppWhiteListSubjects(
|
||||
app.id,
|
||||
initialAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
)
|
||||
const initialSpecificGroups = whiteListSubjectsQuery.data?.groups ?? []
|
||||
const initialSpecificMembers = whiteListSubjectsQuery.data?.members ?? []
|
||||
const draftKey = [
|
||||
app.id,
|
||||
initialAccessMode,
|
||||
initialSpecificGroups.map(group => group.id).join(','),
|
||||
initialSpecificMembers.map(member => member.id).join(','),
|
||||
].join(':')
|
||||
|
||||
return (
|
||||
<AccessControlDraftProvider
|
||||
draftKey={draftKey}
|
||||
initialDraft={{
|
||||
appId: app.id,
|
||||
currentMenu: initialAccessMode,
|
||||
specificGroups: initialSpecificGroups,
|
||||
specificMembers: initialSpecificMembers,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
}}
|
||||
>
|
||||
<AccessControlForm
|
||||
app={app}
|
||||
hideExternalTip={hideExternalTip}
|
||||
subjectsLoading={initialAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS && whiteListSubjectsQuery.isPending}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
successMessage={t('accessControlDialog.updateSuccess', { ns: 'app' })}
|
||||
/>
|
||||
</AccessControlDraftProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessControlForm({
|
||||
app,
|
||||
hideExternalTip,
|
||||
subjectsLoading,
|
||||
successMessage,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
app: App
|
||||
hideExternalTip: boolean
|
||||
subjectsLoading: boolean
|
||||
successMessage: string
|
||||
onClose: () => void
|
||||
onConfirm?: () => void
|
||||
}) {
|
||||
const setAppId = useAccessControlStore(s => s.setAppId)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const { isPending, mutate: updateAccessMode } = useMutation(consoleQuery.explore.updateAppAccessMode.mutationOptions())
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
const hideTip = systemFeatures.webapp_auth.enabled
|
||||
&& (systemFeatures.webapp_auth.allow_sso
|
||||
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||
|
||||
function handleConfirm() {
|
||||
useEffect(() => {
|
||||
setAppId(app.id)
|
||||
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
}, [app, setAppId, setCurrentMenu])
|
||||
|
||||
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
|
||||
const handleConfirm = useCallback(async () => {
|
||||
const submitData: {
|
||||
appId: string
|
||||
accessMode: AccessMode
|
||||
subjects?: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[]
|
||||
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
|
||||
} = { appId: app.id, accessMode: currentMenu }
|
||||
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
const subjects: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[] = []
|
||||
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
|
||||
specificGroups.forEach((group) => {
|
||||
subjects.push({ subjectId: group.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP })
|
||||
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
|
||||
})
|
||||
specificMembers.forEach((member) => {
|
||||
subjects.push({
|
||||
subjectId: member.id,
|
||||
subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
})
|
||||
})
|
||||
submitData.subjects = subjects
|
||||
}
|
||||
updateAccessMode({
|
||||
body: submitData,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(successMessage)
|
||||
onConfirm?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await updateAccessMode(submitData)
|
||||
toast.success(t('accessControlDialog.updateSuccess', { ns: 'app' }))
|
||||
onConfirm?.()
|
||||
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
return (
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<AccessControlDialogContent
|
||||
hideExternalTip={hideExternalTip}
|
||||
saving={isPending}
|
||||
controlsDisabled={subjectsLoading || isPending}
|
||||
confirmDisabled={subjectsLoading}
|
||||
specificGroupsOrMembersProps={{
|
||||
loading: subjectsLoading,
|
||||
}}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t('accessControlDialog.title', { ns: 'app' })}</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">{t('accessControlDialog.description', { ns: 'app' })}</DialogDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 px-6 pb-3">
|
||||
<div className="leading-6">
|
||||
<p className="system-sm-medium text-text-tertiary">{t('accessControlDialog.accessLabel', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<RiBuildingLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.organization', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<SpecificGroupsOrMembers />
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<RiVerifiedBadgeLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.external', { ns: 'app' })}</p>
|
||||
</div>
|
||||
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<div className="flex items-center gap-x-2 p-3">
|
||||
<RiGlobalLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
|
||||
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button disabled={isPending} loading={isPending} variant="primary" onClick={handleConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,30 +1,34 @@
|
||||
'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 { useTranslation } from 'react-i18next'
|
||||
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 { AccessSubjectSelectionList } from './access-subject-selector/selection-list'
|
||||
import { AddMemberOrGroupDialog } from './add-member-or-group-pop'
|
||||
import { useAccessControlStore } from './store'
|
||||
import Loading from '../../base/loading'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
|
||||
export type SpecificGroupsOrMembersProps = {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function SpecificGroupsOrMembers({
|
||||
loading = false,
|
||||
}: SpecificGroupsOrMembersProps) {
|
||||
export default function SpecificGroupsOrMembers() {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const appId = useAccessControlStore(s => s.appId)
|
||||
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)
|
||||
useEffect(() => {
|
||||
setSpecificGroups(data?.groups ?? [])
|
||||
setSpecificMembers(data?.members ?? [])
|
||||
}, [data, setSpecificGroups, setSpecificMembers])
|
||||
|
||||
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
return (
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -35,28 +39,109 @@ export function SpecificGroupsOrMembers({
|
||||
<div>
|
||||
<div className="flex items-center gap-x-1 p-3">
|
||||
<div className="flex grow items-center gap-x-1">
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<AddMemberOrGroupDialog disabled={loading} />
|
||||
<AddMemberOrGroupDialog />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<AccessSubjectSelectionList
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
loading={loading}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
<div className="flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2">
|
||||
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderGroupsAndMembers() {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
if (specificGroups.length <= 0 && specificMembers.length <= 0)
|
||||
return <div className="px-2 pt-5 pb-1.5"><p className="text-center system-xs-regular text-text-tertiary">{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}</p></div>
|
||||
return (
|
||||
<>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.groups', { ns: 'app', count: specificGroups.length ?? 0 })}</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
|
||||
</div>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.members', { ns: 'app', count: specificMembers.length ?? 0 })}</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
function GroupItem({ group }: GroupItemProps) {
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const handleRemoveGroup = useCallback(() => {
|
||||
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
|
||||
}, [group, setSpecificGroups, specificGroups])
|
||||
return (
|
||||
<BaseItem
|
||||
icon={<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" />}
|
||||
onRemove={handleRemoveGroup}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{group.name}</p>
|
||||
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
function MemberItem({ member }: MemberItemProps) {
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const handleRemoveMember = useCallback(() => {
|
||||
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
|
||||
}, [member, setSpecificMembers, specificMembers])
|
||||
return (
|
||||
<BaseItem
|
||||
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
|
||||
onRemove={handleRemoveMember}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{member.name}</p>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onRemove?: () => void
|
||||
}
|
||||
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
|
||||
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
aria-label={t('operation.remove', { ns: 'common' })}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebAppSSONotEnabledTip() {
|
||||
const { t } = useTranslation()
|
||||
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })
|
||||
|
||||
@ -137,14 +137,19 @@ vi.mock('@/app/components/app/overview/embedded', () => ({
|
||||
: null),
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
AccessControl: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
vi.mock('../../app-access-control', () => {
|
||||
const MockAccessControl = ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
<div data-testid="access-control">
|
||||
<button onClick={() => void onConfirm()}>confirm-access-control</button>
|
||||
<button onClick={onClose}>close-access-control</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => (
|
||||
|
||||
@ -38,7 +38,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
|
||||
@ -46,7 +46,7 @@ import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
import { fetchPublishedWorkflow } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { AccessControl } from '../app-access-control'
|
||||
import AccessControl from '../app-access-control'
|
||||
import {
|
||||
PublisherAccessSection,
|
||||
PublisherActionsSection,
|
||||
|
||||
@ -25,14 +25,19 @@ vi.mock('../customize', () => ({
|
||||
default: () => <div data-testid="customize-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
AccessControl: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
|
||||
vi.mock('../../app-access-control', () => {
|
||||
const MockAccessControl = ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
|
||||
<div data-testid="access-control">
|
||||
<button type="button" onClick={onClose}>close-access</button>
|
||||
<button type="button" onClick={onConfirm}>confirm-access</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
describe('app-card-sections', () => {
|
||||
const t = (key: string) => key
|
||||
|
||||
@ -80,14 +80,19 @@ vi.mock('../customize', () => ({
|
||||
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow ? <button data-testid="customize-modal" onClick={onClose}>customize-modal</button> : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
AccessControl: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
vi.mock('../../app-access-control', () => {
|
||||
const MockAccessControl = ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
<div data-testid="access-control-modal">
|
||||
<button onClick={() => void onConfirm()}>confirm-access-control</button>
|
||||
<button onClick={onClose}>close-access-control</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
const mockWindowOpen = vi.fn()
|
||||
Object.defineProperty(window, 'open', {
|
||||
|
||||
@ -37,7 +37,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import ShareQRCode from '@/app/components/base/qrcode'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AccessControl } from '../app-access-control'
|
||||
import AccessControl from '../app-access-control'
|
||||
import CustomizeModal from './customize'
|
||||
import EmbeddedModal from './embedded'
|
||||
import SettingsModal from './settings'
|
||||
|
||||
@ -72,7 +72,7 @@ const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-mod
|
||||
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control').then(mod => mod.AccessControl), {
|
||||
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
|
||||
34
web/context/access-control-store.ts
Normal file
34
web/context/access-control-store.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { create } from 'zustand'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
|
||||
type AccessControlStore = {
|
||||
appId: App['id']
|
||||
setAppId: (appId: App['id']) => void
|
||||
specificGroups: AccessControlGroup[]
|
||||
setSpecificGroups: (specificGroups: AccessControlGroup[]) => void
|
||||
specificMembers: AccessControlAccount[]
|
||||
setSpecificMembers: (specificMembers: AccessControlAccount[]) => void
|
||||
currentMenu: AccessMode
|
||||
setCurrentMenu: (currentMenu: AccessMode) => void
|
||||
selectedGroupsForBreadcrumb: AccessControlGroup[]
|
||||
setSelectedGroupsForBreadcrumb: (selectedGroupsForBreadcrumb: AccessControlGroup[]) => void
|
||||
}
|
||||
|
||||
const useAccessControlStore = create<AccessControlStore>((set) => {
|
||||
return {
|
||||
appId: '',
|
||||
setAppId: appId => set({ appId }),
|
||||
specificGroups: [],
|
||||
setSpecificGroups: specificGroups => set({ specificGroups }),
|
||||
specificMembers: [],
|
||||
setSpecificMembers: specificMembers => set({ specificMembers }),
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
setCurrentMenu: currentMenu => set({ currentMenu }),
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
setSelectedGroupsForBreadcrumb: selectedGroupsForBreadcrumb => set({ selectedGroupsForBreadcrumb }),
|
||||
}
|
||||
})
|
||||
|
||||
export default useAccessControlStore
|
||||
@ -4,7 +4,7 @@ import type {
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AccessMode as AppAccessMode } from '@/models/access-control'
|
||||
import { AccessMode as AppAccessMode, SubjectType } from '@/models/access-control'
|
||||
import {
|
||||
accessControlSelectionFromSubjects,
|
||||
accessModeToPermissionKey,
|
||||
@ -82,6 +82,37 @@ describe('access policy subject conversion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should normalize resolved subjects that use app access-control subject types', () => {
|
||||
expect(normalizeResolvedSubject({
|
||||
subjectId: 'group-1',
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: {
|
||||
id: 'group-1',
|
||||
name: 'Admins',
|
||||
groupSize: 3,
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'group-1',
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
name: 'Admins',
|
||||
memberCount: 3,
|
||||
})
|
||||
|
||||
expect(normalizeResolvedSubject({
|
||||
subjectId: 'account-1',
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: {
|
||||
id: 'account-1',
|
||||
name: 'Member',
|
||||
email: 'member@example.com',
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'account-1',
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
name: 'Member',
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore unsupported subjects and subjects without ids', () => {
|
||||
expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP })).toBeUndefined()
|
||||
expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT })).toBeUndefined()
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { DeploymentAccessControlDialog } from '../deployment-access-control-dialog'
|
||||
|
||||
const mockUseSearchAccessSubjects = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/access-control/use-access-subjects', () => ({
|
||||
useSearchAccessSubjects: (...args: unknown[]) => mockUseSearchAccessSubjects(...args),
|
||||
}))
|
||||
|
||||
describe('DeploymentAccessControlDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSearchAccessSubjects.mockReturnValue({
|
||||
data: { pages: [] },
|
||||
fetchNextPage: vi.fn(),
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should submit the selected deployment access subjects', () => {
|
||||
const handleSubmit = vi.fn()
|
||||
|
||||
render(
|
||||
<DeploymentAccessControlDialog
|
||||
initialDraft={{
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
specificGroups: [{ id: 'group-1', name: 'Group One', groupSize: 2 }],
|
||||
specificMembers: [{ id: 'member-1', name: 'Member One', email: 'member@example.com', avatar: '', avatarUrl: '' }],
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onSubmit={handleSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledWith('specific', {
|
||||
groups: [{ id: 'group-1', name: 'Group One', groupSize: 2 }],
|
||||
members: [{ id: 'member-1', name: 'Member One', email: 'member@example.com', avatar: '', avatarUrl: '' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
|
||||
type DialogProps = {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
show: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function AccessControlDialog({
|
||||
className,
|
||||
children,
|
||||
show,
|
||||
onClose,
|
||||
}: DialogProps) {
|
||||
return (
|
||||
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && onClose?.()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DialogCloseButton className="top-5 right-5 size-8" />
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
|
||||
export function AccessControlItem({ type, children }: PropsWithChildren<{
|
||||
type: AccessMode
|
||||
}>) {
|
||||
return (
|
||||
<RadioRoot<AccessMode>
|
||||
value={type}
|
||||
variant="unstyled"
|
||||
render={<div />}
|
||||
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}
|
||||
</RadioRoot>
|
||||
)
|
||||
}
|
||||
@ -5,7 +5,7 @@ import type {
|
||||
AccessSubjectType as AccessSubjectTypeValue,
|
||||
Subject,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { AccessSubjectSelectionValue } from '@/app/components/app/app-access-control/access-subject-selector/types'
|
||||
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
|
||||
import type {
|
||||
AccessControlAccount,
|
||||
AccessControlGroup,
|
||||
@ -14,7 +14,7 @@ import {
|
||||
AccessMode,
|
||||
AccessSubjectType,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { AccessMode as AppAccessMode } from '@/models/access-control'
|
||||
import { AccessMode as AppAccessMode, SubjectType as AppSubjectType } from '@/models/access-control'
|
||||
|
||||
export type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
|
||||
|
||||
@ -64,7 +64,10 @@ export function appAccessModeToPermissionKey(mode: AppAccessMode): AccessPermiss
|
||||
}
|
||||
|
||||
export function normalizeResolvedSubject(subject: Subject): SelectableAccessSubject | undefined {
|
||||
if (subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP) {
|
||||
const isGroupSubject = subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP || subject.subjectType === AppSubjectType.GROUP
|
||||
const isAccountSubject = subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT || subject.subjectType === AppSubjectType.ACCOUNT
|
||||
|
||||
if (isGroupSubject) {
|
||||
const id = subject.subjectId || subject.groupData?.id
|
||||
if (!id)
|
||||
return undefined
|
||||
@ -77,7 +80,7 @@ export function normalizeResolvedSubject(subject: Subject): SelectableAccessSubj
|
||||
}
|
||||
}
|
||||
|
||||
if (subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT) {
|
||||
if (isAccountSubject) {
|
||||
const id = subject.subjectId || subject.accountData?.id
|
||||
if (!id)
|
||||
return undefined
|
||||
|
||||
@ -21,7 +21,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control/use-app-access-control'
|
||||
import { useSearchAccessSubjects } from '@/service/access-control/use-access-subjects'
|
||||
import { SelectedGroupsBreadCrumb, SubjectItem } from './subject-options'
|
||||
import {
|
||||
getSubjectLabel,
|
||||
@ -56,7 +56,7 @@ export function AccessSubjectAddButton({
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchAccessSubjects({
|
||||
keyword: debouncedKeyword,
|
||||
groupId: lastAvailableGroup?.id,
|
||||
resultsPerPage: 10,
|
||||
@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AccessSubjectAddButton,
|
||||
} from './access-subject-selector/add-button'
|
||||
import { useAccessControlStore } from './store'
|
||||
|
||||
export function AddMemberOrGroupDialog({ disabled = false }: {
|
||||
disabled?: boolean
|
||||
}) {
|
||||
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)
|
||||
|
||||
return (
|
||||
<AccessSubjectAddButton
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
disabled={disabled}
|
||||
breadcrumbGroups={selectedGroupsForBreadcrumb}
|
||||
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AccessPermissionKind,
|
||||
} from './access-policy'
|
||||
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
|
||||
import type { AccessControlDraft } from './store'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessMode as AppAccessMode } from '@/models/access-control'
|
||||
import { AccessControlDialog } from './access-control-dialog'
|
||||
import { AccessControlDialogContent } from './access-control-dialog-content'
|
||||
import {
|
||||
appAccessModeToPermissionKey,
|
||||
} from './access-policy'
|
||||
import { useAccessControlStore } from './store'
|
||||
import { AccessControlDraftProvider } from './store-provider'
|
||||
|
||||
export function DeploymentAccessControlDialog({
|
||||
initialDraft,
|
||||
subjectsLoading,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialDraft: AccessControlDraft
|
||||
subjectsLoading?: boolean
|
||||
saving?: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (kind: AccessPermissionKind, subjects: AccessSubjectSelectionValue) => void
|
||||
}) {
|
||||
const draftKey = [
|
||||
initialDraft.currentMenu,
|
||||
initialDraft.specificGroups ? initialDraft.specificGroups.map(group => group.id).join(',') : 'no-groups',
|
||||
initialDraft.specificMembers ? initialDraft.specificMembers.map(member => member.id).join(',') : 'no-members',
|
||||
].join(':')
|
||||
|
||||
return (
|
||||
<AccessControlDraftProvider draftKey={draftKey} initialDraft={initialDraft}>
|
||||
<DeploymentAccessControlDialogBody
|
||||
subjectsLoading={subjectsLoading}
|
||||
saving={saving}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</AccessControlDraftProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentAccessControlDialogBody({
|
||||
subjectsLoading,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
subjectsLoading?: boolean
|
||||
saving?: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (kind: AccessPermissionKind, subjects: AccessSubjectSelectionValue) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const specificSelected = currentMenu === AppAccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
const selectedSubjectCount = specificGroups.length + specificMembers.length
|
||||
const specificEmpty = specificSelected && selectedSubjectCount === 0
|
||||
const confirmDisabled = saving || (specificSelected && (subjectsLoading || specificEmpty))
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (confirmDisabled)
|
||||
return
|
||||
|
||||
onSubmit(
|
||||
appAccessModeToPermissionKey(currentMenu),
|
||||
specificSelected
|
||||
? { groups: specificGroups, members: specificMembers }
|
||||
: { groups: [], members: [] },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<AccessControlDialogContent
|
||||
title={t('access.permissions.editTitle')}
|
||||
description={t('access.permissions.editDescription')}
|
||||
hideExternal
|
||||
saving={saving}
|
||||
controlsDisabled={saving || subjectsLoading}
|
||||
confirmDisabled={confirmDisabled}
|
||||
specificGroupsOrMembersProps={{
|
||||
loading: subjectsLoading,
|
||||
}}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</AccessControlDialog>
|
||||
)
|
||||
}
|
||||
@ -4,18 +4,10 @@ import type {
|
||||
AccessPermissionKind,
|
||||
SelectableAccessSubject,
|
||||
} from './access-policy'
|
||||
import type { AccessSubjectSelectionValue } from '@/app/components/app/app-access-control/access-subject-selector/types'
|
||||
import type { AccessControlDraft } from '@/app/components/app/app-access-control/store'
|
||||
import { AccessSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { useAccessControlStore } from '@/app/components/app/app-access-control/store'
|
||||
import { AccessControlDraftProvider } from '@/app/components/app/app-access-control/store-provider'
|
||||
import { AccessMode as AppAccessMode } from '@/models/access-control'
|
||||
import {
|
||||
appAccessModeToPermissionKey,
|
||||
permissionIcon,
|
||||
} from './access-policy'
|
||||
|
||||
@ -79,85 +71,3 @@ export function PermissionSummaryButton({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentAccessControlDialog({
|
||||
initialDraft,
|
||||
subjectsLoading,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialDraft: AccessControlDraft
|
||||
subjectsLoading?: boolean
|
||||
saving?: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (kind: AccessPermissionKind, subjects: AccessSubjectSelectionValue) => void
|
||||
}) {
|
||||
const draftKey = [
|
||||
initialDraft.currentMenu,
|
||||
initialDraft.specificGroups ? initialDraft.specificGroups.map(group => group.id).join(',') : 'no-groups',
|
||||
initialDraft.specificMembers ? initialDraft.specificMembers.map(member => member.id).join(',') : 'no-members',
|
||||
].join(':')
|
||||
|
||||
return (
|
||||
<AccessControlDraftProvider draftKey={draftKey} initialDraft={initialDraft}>
|
||||
<DeploymentAccessControlDialogBody
|
||||
subjectsLoading={subjectsLoading}
|
||||
saving={saving}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</AccessControlDraftProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentAccessControlDialogBody({
|
||||
subjectsLoading,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
subjectsLoading?: boolean
|
||||
saving?: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (kind: AccessPermissionKind, subjects: AccessSubjectSelectionValue) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const specificSelected = currentMenu === AppAccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
const selectedSubjectCount = specificGroups.length + specificMembers.length
|
||||
const specificEmpty = specificSelected && selectedSubjectCount === 0
|
||||
const confirmDisabled = saving || (specificSelected && (subjectsLoading || specificEmpty))
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (confirmDisabled)
|
||||
return
|
||||
|
||||
onSubmit(
|
||||
appAccessModeToPermissionKey(currentMenu),
|
||||
specificSelected
|
||||
? { groups: specificGroups, members: specificMembers }
|
||||
: { groups: [], members: [] },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<AccessControlDialogContent
|
||||
title={t('access.permissions.editTitle')}
|
||||
description={t('access.permissions.editDescription')}
|
||||
hideExternal
|
||||
saving={saving}
|
||||
controlsDisabled={saving || subjectsLoading}
|
||||
confirmDisabled={confirmDisabled}
|
||||
specificGroupsOrMembersProps={{
|
||||
loading: subjectsLoading,
|
||||
}}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</AccessControlDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import type {
|
||||
AccessPermissionKind,
|
||||
SelectableAccessSubject,
|
||||
} from './access-policy'
|
||||
import type { AccessSubjectSelectionValue } from '@/app/components/app/app-access-control/access-subject-selector/types'
|
||||
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
@ -25,10 +25,8 @@ import {
|
||||
selectedSubjectsFromPolicy,
|
||||
subjectsFromAccessControlSelection,
|
||||
} from './access-policy'
|
||||
import {
|
||||
DeploymentAccessControlDialog,
|
||||
PermissionSummaryButton,
|
||||
} from './permission-row-components'
|
||||
import { DeploymentAccessControlDialog } from './deployment-access-control-dialog'
|
||||
import { PermissionSummaryButton } from './permission-row-components'
|
||||
|
||||
type AccessPermissionDraft = {
|
||||
fingerprint: string
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AccessSubjectSelectionList } from './access-subject-selector/selection-list'
|
||||
import { AddMemberOrGroupDialog } from './add-member-or-group-pop'
|
||||
import { useAccessControlStore } from './store'
|
||||
|
||||
export type SpecificGroupsOrMembersProps = {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function SpecificGroupsOrMembers({
|
||||
loading = false,
|
||||
}: SpecificGroupsOrMembersProps) {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
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()
|
||||
|
||||
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
return (
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-x-1 p-3">
|
||||
<div className="flex grow items-center gap-x-1">
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<AddMemberOrGroupDialog disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<AccessSubjectSelectionList
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
loading={loading}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebAppSSONotEnabledTip() {
|
||||
const { t } = useTranslation()
|
||||
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })
|
||||
|
||||
return (
|
||||
<Infotip
|
||||
aria-label={tip}
|
||||
iconClassName="h-4 w-4 shrink-0 text-text-warning-secondary hover:text-text-warning-secondary"
|
||||
>
|
||||
{tip}
|
||||
</Infotip>
|
||||
)
|
||||
}
|
||||
59
web/service/access-control/__tests__/index.spec.tsx
Normal file
59
web/service/access-control/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { post } from '@/service/base'
|
||||
import { useUpdateAccessMode } from '..'
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('access-control service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(post).mockResolvedValue({})
|
||||
})
|
||||
|
||||
// Access mode updates keep the legacy webapp whitelist payload contract.
|
||||
describe('Mutations', () => {
|
||||
it('should update access mode with legacy subject type values', async () => {
|
||||
const { result } = renderHook(() => useUpdateAccessMode(), { wrapper: createWrapper() })
|
||||
|
||||
result.current.mutate({
|
||||
appId: 'app-1',
|
||||
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
subjects: [
|
||||
{ subjectId: 'group-1', subjectType: SubjectType.GROUP },
|
||||
{ subjectId: 'account-1', subjectType: SubjectType.ACCOUNT },
|
||||
],
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(post).toHaveBeenCalledWith('/enterprise/webapp/app/access-mode', {
|
||||
body: {
|
||||
appId: 'app-1',
|
||||
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
subjects: [
|
||||
{ subjectId: 'group-1', subjectType: SubjectType.GROUP },
|
||||
{ subjectId: 'account-1', subjectType: SubjectType.ACCOUNT },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useSearchAccessSubjects } from '../use-access-subjects'
|
||||
|
||||
const mockSearchForWhilteListCandidates = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
enterprise: {
|
||||
webAppAuth: {
|
||||
searchForWhilteListCandidates: (...args: unknown[]) => mockSearchForWhilteListCandidates(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('use-access-subjects', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchForWhilteListCandidates.mockResolvedValue({
|
||||
currPage: 1,
|
||||
subjects: [],
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should search access subject candidates with the generated enterprise client', async () => {
|
||||
renderHook(
|
||||
() => useSearchAccessSubjects({
|
||||
keyword: 'team one',
|
||||
groupId: 'group-1',
|
||||
resultsPerPage: 20,
|
||||
}, true),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSearchForWhilteListCandidates).toHaveBeenCalledWith({
|
||||
query: {
|
||||
keyword: 'team one',
|
||||
groupId: 'group-1',
|
||||
resultsPerPage: 20,
|
||||
pageNumber: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
57
web/service/access-control/index.ts
Normal file
57
web/service/access-control/index.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import type { AccessControlGroup, AccessMode, Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { post } from '../base'
|
||||
import {
|
||||
useAppWhiteListSubjects as useAppWhiteListSubjectsBase,
|
||||
useGetUserCanAccessApp as useGetUserCanAccessAppBase,
|
||||
useSearchForWhiteListCandidates as useSearchForWhiteListCandidatesBase,
|
||||
} from './use-app-access-control'
|
||||
|
||||
const NAME_SPACE = 'access-control'
|
||||
|
||||
type UpdateAccessModeParams = {
|
||||
appId: App['id']
|
||||
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
|
||||
accessMode: AccessMode
|
||||
}
|
||||
|
||||
type SearchForWhiteListCandidatesQuery = {
|
||||
keyword?: string
|
||||
groupId?: AccessControlGroup['id']
|
||||
resultsPerPage?: number
|
||||
}
|
||||
|
||||
type UserCanAccessAppParams = {
|
||||
appId?: string
|
||||
isInstalledApp?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const useAppWhiteListSubjects = (appId: string | undefined, enabled: boolean) => {
|
||||
return useAppWhiteListSubjectsBase(appId, enabled)
|
||||
}
|
||||
|
||||
export const useSearchForWhiteListCandidates = (query: SearchForWhiteListCandidatesQuery, enabled: boolean) => {
|
||||
return useSearchForWhiteListCandidatesBase(query, enabled)
|
||||
}
|
||||
|
||||
export const useGetUserCanAccessApp = (params: UserCanAccessAppParams) => {
|
||||
return useGetUserCanAccessAppBase(params)
|
||||
}
|
||||
|
||||
export const useUpdateAccessMode = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'update-access-mode'],
|
||||
mutationFn: (params: UpdateAccessModeParams) => {
|
||||
return post('/enterprise/webapp/app/access-mode', { body: params })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [NAME_SPACE, 'app-whitelist-subjects'],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
52
web/service/access-control/use-access-subjects.ts
Normal file
52
web/service/access-control/use-access-subjects.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { consoleClient } from '../client'
|
||||
|
||||
const NAME_SPACE = 'access-control'
|
||||
|
||||
type SearchAccessSubjectsResult = {
|
||||
currPage: number
|
||||
totalPages: number
|
||||
subjects: Subject[]
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export type SearchAccessSubjectsQuery = {
|
||||
keyword?: string
|
||||
groupId?: AccessControlGroup['id']
|
||||
resultsPerPage?: number
|
||||
}
|
||||
|
||||
export const useSearchAccessSubjects = (query: SearchAccessSubjectsQuery, enabled: boolean) => {
|
||||
const { keyword, groupId, resultsPerPage } = query
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: [NAME_SPACE, 'access-subject-candidates', keyword, groupId, resultsPerPage],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await consoleClient.enterprise.webAppAuth.searchForWhilteListCandidates({
|
||||
query: {
|
||||
...(keyword ? { keyword } : {}),
|
||||
...(groupId ? { groupId } : {}),
|
||||
...(resultsPerPage ? { resultsPerPage } : {}),
|
||||
pageNumber: pageParam as number,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
currPage: response.currPage ?? (pageParam as number),
|
||||
totalPages: 0,
|
||||
subjects: (response.subjects ?? []) as Subject[],
|
||||
hasMore: response.hasMore ?? false,
|
||||
} satisfies SearchAccessSubjectsResult
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.hasMore)
|
||||
return lastPage.currPage + 1
|
||||
return undefined
|
||||
},
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user