fix: decouple deployment access control dialog (#37936)

This commit is contained in:
Stephen Zhou 2026-06-26 10:16:29 +08:00 committed by GitHub
parent 1c5e1280cb
commit f4e832f35c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1444 additions and 684 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}: {

View File

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

View File

@ -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',

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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([])
})
})

View File

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

View File

@ -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([])
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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', {

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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'],
})
},
})
}

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