mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
feat(web): add webapp access control to agent access point (#39285)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
parent
11016563b1
commit
eee8d6cf7b
@ -386,14 +386,6 @@
|
||||
"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
|
||||
|
||||
@ -1,45 +1,50 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import AccessControlItem from '../access-control-item'
|
||||
|
||||
describe('AccessControlItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAccessControlStore.setState({
|
||||
appId: '',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: AccessMode.PUBLIC,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
})
|
||||
function AccessOptions({ initialValue = AccessMode.PUBLIC }: { initialValue?: AccessMode }) {
|
||||
const [value, setValue] = useState<AccessMode>(initialValue)
|
||||
|
||||
return (
|
||||
<RadioGroup<AccessMode> aria-label="Access" value={value} onValueChange={setValue}>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>Organization Only</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>Anyone</AccessControlItem>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
it('should expose a single-select radio group and update the checked option', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AccessOptions />)
|
||||
|
||||
const organization = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
const anyone = screen.getByRole('radio', { name: 'Anyone' })
|
||||
|
||||
expect(screen.getByRole('radiogroup', { name: 'Access' })).toBeInTheDocument()
|
||||
expect(organization).not.toBeChecked()
|
||||
expect(anyone).toBeChecked()
|
||||
|
||||
await user.click(organization)
|
||||
|
||||
expect(organization).toBeChecked()
|
||||
expect(anyone).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('should update current menu when selecting a different access type', () => {
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
it('should support arrow-key selection between options', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AccessOptions initialValue={AccessMode.ORGANIZATION} />)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
fireEvent.click(option)
|
||||
const organization = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
const anyone = screen.getByRole('radio', { name: 'Anyone' })
|
||||
expect(organization).toBeChecked()
|
||||
organization.focus()
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
it('should keep the selected state for the active access type', () => {
|
||||
useAccessControlStore.setState({
|
||||
currentMenu: AccessMode.ORGANIZATION,
|
||||
})
|
||||
|
||||
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')
|
||||
expect(anyone).toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,17 +7,14 @@ import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
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 mockMutateAsync = vi.fn()
|
||||
const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
const { mockMutateAsync } = vi.hoisted(() => ({
|
||||
mockMutateAsync: vi.fn(),
|
||||
}))
|
||||
const intersectionObserverMocks = vi.hoisted(() => ({
|
||||
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
|
||||
@ -27,9 +24,35 @@ vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) =>
|
||||
mockUseSearchForWhiteListCandidates(...args),
|
||||
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
const webAppAuth = new Proxy(actual.consoleQuery.enterprise.webAppAuth, {
|
||||
get(target, property, receiver) {
|
||||
if (property === 'updateWebAppWhitelistSubjects')
|
||||
return { mutationOptions: () => ({ mutationFn: mockMutateAsync }) }
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
const enterprise = new Proxy(actual.consoleQuery.enterprise, {
|
||||
get(target, property, receiver) {
|
||||
if (property === 'webAppAuth') return webAppAuth
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: new Proxy(actual.consoleQuery, {
|
||||
get(target, property, receiver) {
|
||||
if (property === 'enterprise') return enterprise
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/account-state', async () => {
|
||||
const { atom } = await vi.importActual<typeof import('jotai')>('jotai')
|
||||
return {
|
||||
@ -99,11 +122,8 @@ beforeAll(() => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
mockUseUpdateAccessMode.mockReturnValue({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
})
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: false,
|
||||
data: {
|
||||
@ -119,39 +139,6 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// AccessControlItem handles selected vs. unselected styling and click state updates
|
||||
describe('AccessControlItem', () => {
|
||||
it('should update current menu when selecting a different access type', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
expect(option).toHaveClass('cursor-pointer')
|
||||
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
|
||||
it('should keep current menu when clicking the selected access type', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
})
|
||||
|
||||
// AccessControlDialog renders the shared dialog primitive with a close control.
|
||||
describe('AccessControlDialog', () => {
|
||||
it('should render dialog content when visible', () => {
|
||||
@ -355,13 +342,15 @@ describe('AccessControl', () => {
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
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(mockMutateAsync.mock.calls[0]?.[0]).toEqual({
|
||||
body: {
|
||||
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()
|
||||
|
||||
@ -2,6 +2,7 @@ import type { ReactElement } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
@ -19,10 +20,8 @@ const render = (ui: ReactElement) =>
|
||||
systemFeatures: { webapp_auth: mockWebappAuth },
|
||||
})
|
||||
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
const { mockMutateAsync } = vi.hoisted(() => ({
|
||||
mockMutateAsync: vi.fn(),
|
||||
}))
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
@ -31,7 +30,19 @@ vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) =>
|
||||
mockUseSearchForWhiteListCandidates(...args),
|
||||
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
systemFeatures: { get: { queryKey: () => ['system-features'] } },
|
||||
enterprise: {
|
||||
webAppAuth: {
|
||||
updateWebAppWhitelistSubjects: {
|
||||
mutationOptions: () => ({ mutationFn: mockMutateAsync }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AccessControl', () => {
|
||||
@ -85,9 +96,11 @@ describe('AccessControl', () => {
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
expect(mockMutateAsync.mock.calls[0]?.[0]).toEqual({
|
||||
body: {
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
},
|
||||
})
|
||||
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
@ -112,4 +125,20 @@ describe('AccessControl', () => {
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve an unfinished selection when the parent rerenders', async () => {
|
||||
const user = userEvent.setup()
|
||||
const app = { id: 'app-id-3', access_mode: AccessMode.PUBLIC } as App
|
||||
const { rerender } = render(<AccessControl app={app} onClose={vi.fn()} />)
|
||||
|
||||
const organization = screen.getByRole('radio', {
|
||||
name: 'app.accessControlDialog.accessItems.organization',
|
||||
})
|
||||
await user.click(organization)
|
||||
expect(organization).toBeChecked()
|
||||
|
||||
rerender(<AccessControl app={{ ...app }} onClose={vi.fn()} />)
|
||||
|
||||
expect(organization).toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,33 +1,26 @@
|
||||
'use client'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioItem } from '@langgenius/dify-ui/radio'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AccessControlItem({ type, children }: AccessControlItemProps) {
|
||||
return (
|
||||
<div className="rounded-[10px] border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm">
|
||||
<RadioItem<AccessMode>
|
||||
value={type}
|
||||
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:inset-ring-[0.5px] data-checked:inset-ring-components-option-card-option-selected-border',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
AccessControlItem.displayName = 'AccessControlItem'
|
||||
|
||||
export default AccessControlItem
|
||||
|
||||
@ -3,27 +3,30 @@ import type { Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { useUpdateAccessMode } from '@/service/access-control'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
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
|
||||
app: Pick<App, 'id' | 'access_mode'>
|
||||
onClose: () => void
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export default function AccessControl(props: AccessControlProps) {
|
||||
const { app, onClose, onConfirm } = props
|
||||
const { id: appId, access_mode: appAccessMode } = app
|
||||
const accessControlOptionsLabelId = useId()
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const setAppId = useAccessControlStore((s) => s.setAppId)
|
||||
@ -38,17 +41,19 @@ export default function AccessControl(props: AccessControlProps) {
|
||||
systemFeatures.webapp_auth.allow_email_code_login)
|
||||
|
||||
useEffect(() => {
|
||||
setAppId(app.id)
|
||||
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
}, [app, setAppId, setCurrentMenu])
|
||||
setAppId(appId)
|
||||
setCurrentMenu(appAccessMode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
}, [appAccessMode, appId, setAppId, setCurrentMenu])
|
||||
|
||||
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
|
||||
const { isPending, mutateAsync: updateAccessMode } = useMutation(
|
||||
consoleQuery.enterprise.webAppAuth.updateWebAppWhitelistSubjects.mutationOptions(),
|
||||
)
|
||||
const handleConfirm = useCallback(async () => {
|
||||
const submitData: {
|
||||
appId: string
|
||||
accessMode: AccessMode
|
||||
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
|
||||
} = { appId: app.id, accessMode: currentMenu }
|
||||
} = { appId, accessMode: currentMenu }
|
||||
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
|
||||
specificGroups.forEach((group) => {
|
||||
@ -62,10 +67,10 @@ export default function AccessControl(props: AccessControlProps) {
|
||||
})
|
||||
submitData.subjects = subjects
|
||||
}
|
||||
await updateAccessMode(submitData)
|
||||
await updateAccessMode({ body: submitData })
|
||||
toast.success(t(($) => $['accessControlDialog.updateSuccess'], { ns: 'app' }))
|
||||
onConfirm?.()
|
||||
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
}, [updateAccessMode, appId, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
return (
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
@ -77,9 +82,14 @@ export default function AccessControl(props: AccessControlProps) {
|
||||
{t(($) => $['accessControlDialog.description'], { ns: 'app' })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 px-6 pb-3">
|
||||
<RadioGroup<AccessMode>
|
||||
value={currentMenu}
|
||||
onValueChange={setCurrentMenu}
|
||||
className="flex flex-col items-stretch gap-y-1 px-6 pb-3"
|
||||
aria-labelledby={accessControlOptionsLabelId}
|
||||
>
|
||||
<div className="leading-6">
|
||||
<p className="system-sm-medium text-text-tertiary">
|
||||
<p id={accessControlOptionsLabelId} className="system-sm-medium text-text-tertiary">
|
||||
{t(($) => $['accessControlDialog.accessLabel'], { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
@ -115,7 +125,7 @@ export default function AccessControl(props: AccessControlProps) {
|
||||
</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
|
||||
<Button onClick={onClose}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button>
|
||||
<Button
|
||||
|
||||
@ -3,6 +3,7 @@ import type React from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { ServiceApiAccessCard } from '../service-api-access-card'
|
||||
import { WebAppAccessCard } from '../web-app-access-card'
|
||||
@ -16,6 +17,14 @@ const mocks = vi.hoisted(() => ({
|
||||
apiEnableMutation: vi.fn(),
|
||||
createApiKeyMutation: vi.fn(),
|
||||
deleteApiKeyMutation: vi.fn(),
|
||||
accessControlRender: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
default: ({ app }: { app: { id: string; access_mode: string } }) => {
|
||||
mocks.accessControlRender(app)
|
||||
return <div role="dialog" aria-label="access-control" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
@ -115,6 +124,11 @@ vi.mock('@/context/version-state', async () => {
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
systemFeatures: {
|
||||
get: {
|
||||
queryKey: () => ['system-features'],
|
||||
},
|
||||
},
|
||||
apps: {
|
||||
byAppId: {
|
||||
siteEnable: {
|
||||
@ -207,6 +221,7 @@ function createAgent(overrides: Partial<AgentAppDetailWithSite> = {}): AgentAppD
|
||||
mode: 'agent',
|
||||
name: 'Support Agent',
|
||||
app_id: 'app-1',
|
||||
backing_app_id: 'app-1',
|
||||
api_base_url: 'https://api.example.test/v1',
|
||||
access_mode: 'sso_verified',
|
||||
site: {
|
||||
@ -226,16 +241,19 @@ function createAgent(overrides: Partial<AgentAppDetailWithSite> = {}): AgentAppD
|
||||
}
|
||||
}
|
||||
|
||||
function renderWithQueryClient(ui: React.ReactElement) {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
function renderWithQueryClient(
|
||||
ui: React.ReactElement,
|
||||
{ webAppAuthEnabled = true }: { webAppAuthEnabled?: boolean } = {},
|
||||
) {
|
||||
const queryClient = createConsoleQueryClient(webAppAuthEnabled)
|
||||
|
||||
render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
|
||||
|
||||
return queryClient
|
||||
}
|
||||
|
||||
function createConsoleQueryClient() {
|
||||
return new QueryClient({
|
||||
function createConsoleQueryClient(webAppAuthEnabled = true) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
@ -245,6 +263,12 @@ function createConsoleQueryClient() {
|
||||
},
|
||||
},
|
||||
})
|
||||
seedSystemFeatures(queryClient, {
|
||||
webapp_auth: {
|
||||
enabled: webAppAuthEnabled,
|
||||
},
|
||||
})
|
||||
return queryClient
|
||||
}
|
||||
|
||||
describe('Agent access surface cards', () => {
|
||||
@ -673,4 +697,95 @@ describe('Agent access surface cards', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Web app access control', () => {
|
||||
const accessControlAgent = () =>
|
||||
createAgent({
|
||||
access_mode: 'private',
|
||||
maintainer: 'user-1',
|
||||
permission_keys: ['app.acl.release_and_version'],
|
||||
})
|
||||
|
||||
const accessControlButtonName = 'agentV2.agentDetail.access.webApp.actions.accessControl'
|
||||
|
||||
it('should render the access control button when webapp auth is enabled and user can manage', () => {
|
||||
renderWithQueryClient(
|
||||
<WebAppAccessCard agent={accessControlAgent()} agentId="agent-1" isLoading={false} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: accessControlButtonName })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the access control button when webapp auth is disabled', () => {
|
||||
renderWithQueryClient(
|
||||
<WebAppAccessCard agent={accessControlAgent()} agentId="agent-1" isLoading={false} />,
|
||||
{ webAppAuthEnabled: false },
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: accessControlButtonName }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the access control button when the user cannot manage access control', () => {
|
||||
renderWithQueryClient(
|
||||
<WebAppAccessCard
|
||||
agent={createAgent({ access_mode: 'private', permission_keys: [] })}
|
||||
agentId="agent-1"
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: accessControlButtonName }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([null, 'future-access-mode'])(
|
||||
'should hide the access control button when the access mode is %s',
|
||||
(accessMode) => {
|
||||
renderWithQueryClient(
|
||||
<WebAppAccessCard
|
||||
agent={createAgent({
|
||||
access_mode: accessMode,
|
||||
maintainer: 'user-1',
|
||||
permission_keys: ['app.acl.release_and_version'],
|
||||
})}
|
||||
agentId="agent-1"
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: accessControlButtonName }),
|
||||
).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('should open the access control dialog wired with the backing app id', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithQueryClient(
|
||||
<WebAppAccessCard
|
||||
agent={createAgent({
|
||||
access_mode: 'private',
|
||||
app_id: 'source-app-1',
|
||||
backing_app_id: 'backing-app-1',
|
||||
maintainer: 'user-1',
|
||||
permission_keys: ['app.acl.release_and_version'],
|
||||
})}
|
||||
agentId="agent-1"
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: accessControlButtonName }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'access-control' })).toBeInTheDocument()
|
||||
expect(mocks.accessControlRender).toHaveBeenCalledWith({
|
||||
id: 'backing-app-1',
|
||||
access_mode: 'private',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,6 +17,7 @@ import { AccessMode } from '@/models/access-control'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { accessSurfaceActionClassName, AccessSurfaceCard } from './access-surface-card'
|
||||
import { WebAppAccessControlButton } from './web-app-access-control-button'
|
||||
|
||||
export function WebAppAccessCard({
|
||||
agent,
|
||||
@ -266,6 +267,7 @@ export function WebAppAccessCard({
|
||||
<span aria-hidden className="i-ri-palette-line size-4" />
|
||||
{t(($) => $['agentDetail.access.webApp.actions.settings'])}
|
||||
</Button>
|
||||
<WebAppAccessControlButton agent={agent} />
|
||||
{settingsAppInfo && (
|
||||
<SettingsModal
|
||||
isChat
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { isAccessMode } from '@/models/access-control'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { getAppACLCapabilities } from '@/utils/permission'
|
||||
|
||||
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export function WebAppAccessControlButton({ agent }: { agent?: AgentAppDetailWithSite }) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [showAccessControl, setShowAccessControl] = useState(false)
|
||||
const appId = agent?.backing_app_id
|
||||
const rawAccessMode = agent?.access_mode
|
||||
const accessMode = isAccessMode(rawAccessMode) ? rawAccessMode : undefined
|
||||
const { data: webAppAuthEnabled } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: (systemFeatures) => systemFeatures.webapp_auth.enabled,
|
||||
})
|
||||
const currentUserId = useAtomValue(userProfileIdAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const { canReleaseAndVersion: canManageWebAppAccessControl } = getAppACLCapabilities(
|
||||
agent?.permission_keys,
|
||||
{
|
||||
currentUserId,
|
||||
resourceMaintainer: agent?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
},
|
||||
)
|
||||
|
||||
if (!webAppAuthEnabled || !canManageWebAppAccessControl || !appId || !accessMode) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
className="gap-1.5 px-3"
|
||||
onClick={() => setShowAccessControl(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-lock-2-line size-4" />
|
||||
{t(($) => $['agentDetail.access.webApp.actions.accessControl'])}
|
||||
</Button>
|
||||
{showAccessControl && (
|
||||
<AccessControl
|
||||
app={{ id: appId, access_mode: accessMode }}
|
||||
onClose={() => setShowAccessControl(false)}
|
||||
onConfirm={() => setShowAccessControl(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PromptEditorProps } from '@/app/components/base/prompt-editor'
|
||||
import type { AgentTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { API_PREFIX } from '@/config'
|
||||
@ -229,13 +229,8 @@ const renderAgentPromptEditor = (
|
||||
return {
|
||||
store,
|
||||
...view,
|
||||
rerenderWithValue: (nextValue: string) => {
|
||||
store.set(agentComposerPromptAtom, nextValue)
|
||||
view.rerender(
|
||||
<JotaiProvider store={store}>
|
||||
<AgentPromptEditor />
|
||||
</JotaiProvider>,
|
||||
)
|
||||
setPromptValue: (nextValue: string) => {
|
||||
act(() => store.set(agentComposerPromptAtom, nextValue))
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -451,8 +446,7 @@ describe('AgentPromptEditor', () => {
|
||||
// Prompt slash commands should use the Agent Roster category menu and replace it with submenus.
|
||||
describe('Slash Commands', () => {
|
||||
it('should open category menu, show skill submenu, and append the selected reference', async () => {
|
||||
const { store, rerenderWithValue, container } =
|
||||
renderAgentPromptEditor('Review these tenders')
|
||||
const { store, setPromptValue, container } = renderAgentPromptEditor('Review these tenders')
|
||||
|
||||
expect(mockPromptEditor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -464,7 +458,7 @@ describe('AgentPromptEditor', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
rerenderWithValue('Review these tenders/')
|
||||
setPromptValue('Review these tenders/')
|
||||
await openSlashMenuFromEditor()
|
||||
expect(container).toContainElement(
|
||||
screen.getByRole('dialog', { name: /agentDetail\.configure\.prompt\.insert\.label/i }),
|
||||
@ -820,7 +814,7 @@ describe('AgentPromptEditor', () => {
|
||||
})
|
||||
|
||||
it('should append available provider tool references and add missing tools to the configuration', async () => {
|
||||
const { store, rerenderWithValue } = renderAgentPromptEditor('Research/', { tools: [] })
|
||||
const { store, setPromptValue } = renderAgentPromptEditor('Research/', { tools: [] })
|
||||
const expectedProviderIcon = `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=duckduckgo.svg`
|
||||
|
||||
await openSlashMenuFromEditor()
|
||||
@ -851,7 +845,7 @@ describe('AgentPromptEditor', () => {
|
||||
}),
|
||||
])
|
||||
|
||||
rerenderWithValue('Research/')
|
||||
setPromptValue('Research/')
|
||||
await openSlashMenuFromEditor()
|
||||
fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.tools\.label/i }))
|
||||
fireEvent.click(
|
||||
@ -872,36 +866,45 @@ describe('AgentPromptEditor', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should close slash menu when slash is deleted or the user clicks outside', async () => {
|
||||
const { rerenderWithValue } = renderAgentPromptEditor('Review/')
|
||||
it('should close the slash menu when the trailing slash is deleted', async () => {
|
||||
const { setPromptValue } = renderAgentPromptEditor('Review/')
|
||||
|
||||
await openSlashMenuFromEditor()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
rerenderWithValue('Review')
|
||||
setPromptValue('Review')
|
||||
fireEvent.keyUp(screen.getByRole('textbox'), { key: 'Backspace' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /agentDetail\.configure\.skills\.label/i }),
|
||||
screen.queryByRole('dialog', {
|
||||
name: /agentDetail\.configure\.prompt\.insert\.label/i,
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
rerenderWithValue('Review/')
|
||||
await openSlashMenuFromEditor()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }),
|
||||
).toBeInTheDocument()
|
||||
it('should close the slash menu when the user clicks outside', async () => {
|
||||
const user = userEvent.setup()
|
||||
const outsideButton = document.createElement('button')
|
||||
outsideButton.textContent = 'Outside'
|
||||
document.body.append(outsideButton)
|
||||
|
||||
fireEvent.pointerDown(document.body)
|
||||
try {
|
||||
renderAgentPromptEditor('Review/')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /agentDetail\.configure\.skills\.label/i }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
await openSlashMenuFromEditor()
|
||||
await user.click(outsideButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', {
|
||||
name: /agentDetail\.configure\.prompt\.insert\.label/i,
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
} finally {
|
||||
outsideButton.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('should close the slash menu when focus moves outside the prompt editor', async () => {
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "نقطة الوصول",
|
||||
"agentDetail.access.toggleSurface": "تبديل وصول {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "رابط الوصول",
|
||||
"agentDetail.access.webApp.actions.accessControl": "التحكم في الوصول",
|
||||
"agentDetail.access.webApp.actions.customize": "واجهة أمامية مخصصة",
|
||||
"agentDetail.access.webApp.actions.embedded": "مضمّن",
|
||||
"agentDetail.access.webApp.actions.launch": "تشغيل",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Zugangspunkt",
|
||||
"agentDetail.access.toggleSurface": "Zugriff von {{name}} umschalten",
|
||||
"agentDetail.access.webApp.accessUrl": "Zugriffs-URL",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Zugriffskontrolle",
|
||||
"agentDetail.access.webApp.actions.customize": "Benutzerdefiniertes Frontend",
|
||||
"agentDetail.access.webApp.actions.embedded": "Eingebettet",
|
||||
"agentDetail.access.webApp.actions.launch": "Starten",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Access Point",
|
||||
"agentDetail.access.toggleSurface": "Toggle {{name}} access",
|
||||
"agentDetail.access.webApp.accessUrl": "Access URL",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Access Control",
|
||||
"agentDetail.access.webApp.actions.customize": "Custom Frontend",
|
||||
"agentDetail.access.webApp.actions.embedded": "Embedded",
|
||||
"agentDetail.access.webApp.actions.launch": "Launch",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Punto de acceso",
|
||||
"agentDetail.access.toggleSurface": "Alternar acceso de {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL de acceso",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Control de Acceso",
|
||||
"agentDetail.access.webApp.actions.customize": "Frontend personalizado",
|
||||
"agentDetail.access.webApp.actions.embedded": "Incrustado",
|
||||
"agentDetail.access.webApp.actions.launch": "Iniciar",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "نقطه دسترسی",
|
||||
"agentDetail.access.toggleSurface": "تغییر دسترسی {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL دسترسی",
|
||||
"agentDetail.access.webApp.actions.accessControl": "کنترل دسترسی",
|
||||
"agentDetail.access.webApp.actions.customize": "فرانتاند سفارشی",
|
||||
"agentDetail.access.webApp.actions.embedded": "تعبیهشده",
|
||||
"agentDetail.access.webApp.actions.launch": "راهاندازی",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Point d’accès",
|
||||
"agentDetail.access.toggleSurface": "Basculer l’accès de {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL d’accès",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Contrôle d'accès",
|
||||
"agentDetail.access.webApp.actions.customize": "Frontend personnalisé",
|
||||
"agentDetail.access.webApp.actions.embedded": "Intégré",
|
||||
"agentDetail.access.webApp.actions.launch": "Lancer",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "एक्सेस पॉइंट",
|
||||
"agentDetail.access.toggleSurface": "{{name}} एक्सेस टॉगल करें",
|
||||
"agentDetail.access.webApp.accessUrl": "एक्सेस URL",
|
||||
"agentDetail.access.webApp.actions.accessControl": "पहुँच नियंत्रण",
|
||||
"agentDetail.access.webApp.actions.customize": "कस्टम फ्रंटएंड",
|
||||
"agentDetail.access.webApp.actions.embedded": "एम्बेडेड",
|
||||
"agentDetail.access.webApp.actions.launch": "लॉन्च करें",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Titik Akses",
|
||||
"agentDetail.access.toggleSurface": "Alihkan akses {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL Akses",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Kontrol Akses",
|
||||
"agentDetail.access.webApp.actions.customize": "Frontend Kustom",
|
||||
"agentDetail.access.webApp.actions.embedded": "Tertanam",
|
||||
"agentDetail.access.webApp.actions.launch": "Luncurkan",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Punto di accesso",
|
||||
"agentDetail.access.toggleSurface": "Attiva/disattiva accesso di {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL di accesso",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Controllo di accesso",
|
||||
"agentDetail.access.webApp.actions.customize": "Frontend personalizzato",
|
||||
"agentDetail.access.webApp.actions.embedded": "Incorporato",
|
||||
"agentDetail.access.webApp.actions.launch": "Avvia",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "アクセスポイント",
|
||||
"agentDetail.access.toggleSurface": "{{name}} のアクセスを切り替え",
|
||||
"agentDetail.access.webApp.accessUrl": "アクセス URL",
|
||||
"agentDetail.access.webApp.actions.accessControl": "アクセス制御",
|
||||
"agentDetail.access.webApp.actions.customize": "カスタムフロントエンド",
|
||||
"agentDetail.access.webApp.actions.embedded": "埋め込み",
|
||||
"agentDetail.access.webApp.actions.launch": "起動",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "액세스 지점",
|
||||
"agentDetail.access.toggleSurface": "{{name}} 액세스 전환",
|
||||
"agentDetail.access.webApp.accessUrl": "액세스 URL",
|
||||
"agentDetail.access.webApp.actions.accessControl": "접근 제어",
|
||||
"agentDetail.access.webApp.actions.customize": "커스텀 프런트엔드",
|
||||
"agentDetail.access.webApp.actions.embedded": "임베드",
|
||||
"agentDetail.access.webApp.actions.launch": "실행",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Toegangspunt",
|
||||
"agentDetail.access.toggleSurface": "Toegang van {{name}} schakelen",
|
||||
"agentDetail.access.webApp.accessUrl": "Toegangs-URL",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Toegangsbeheer",
|
||||
"agentDetail.access.webApp.actions.customize": "Aangepaste frontend",
|
||||
"agentDetail.access.webApp.actions.embedded": "Ingesloten",
|
||||
"agentDetail.access.webApp.actions.launch": "Starten",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Punkt dostępu",
|
||||
"agentDetail.access.toggleSurface": "Przełącz dostęp {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL dostępu",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Kontrola dostępu",
|
||||
"agentDetail.access.webApp.actions.customize": "Niestandardowy frontend",
|
||||
"agentDetail.access.webApp.actions.embedded": "Osadzony",
|
||||
"agentDetail.access.webApp.actions.launch": "Uruchom",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Ponto de acesso",
|
||||
"agentDetail.access.toggleSurface": "Alternar acesso de {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL de acesso",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Controle de Acesso",
|
||||
"agentDetail.access.webApp.actions.customize": "Frontend personalizado",
|
||||
"agentDetail.access.webApp.actions.embedded": "Incorporado",
|
||||
"agentDetail.access.webApp.actions.launch": "Iniciar",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Punct de acces",
|
||||
"agentDetail.access.toggleSurface": "Comută accesul pentru {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL de acces",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Controlul Accesului",
|
||||
"agentDetail.access.webApp.actions.customize": "Frontend personalizat",
|
||||
"agentDetail.access.webApp.actions.embedded": "Încorporat",
|
||||
"agentDetail.access.webApp.actions.launch": "Lansează",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Точка доступа",
|
||||
"agentDetail.access.toggleSurface": "Переключить доступ {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL доступа",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Управление доступом",
|
||||
"agentDetail.access.webApp.actions.customize": "Пользовательский фронтенд",
|
||||
"agentDetail.access.webApp.actions.embedded": "Встроить",
|
||||
"agentDetail.access.webApp.actions.launch": "Запустить",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Dostopna točka",
|
||||
"agentDetail.access.toggleSurface": "Preklopi dostop {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL dostopa",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Nadzor dostopa",
|
||||
"agentDetail.access.webApp.actions.customize": "Frontend po meri",
|
||||
"agentDetail.access.webApp.actions.embedded": "Vgrajeno",
|
||||
"agentDetail.access.webApp.actions.launch": "Zaženi",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "จุดเข้าถึง",
|
||||
"agentDetail.access.toggleSurface": "สลับการเข้าถึง {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL การเข้าถึง",
|
||||
"agentDetail.access.webApp.actions.accessControl": "การควบคุมการเข้าถึง",
|
||||
"agentDetail.access.webApp.actions.customize": "ฟรอนต์เอนด์ที่กำหนดเอง",
|
||||
"agentDetail.access.webApp.actions.embedded": "ฝัง",
|
||||
"agentDetail.access.webApp.actions.launch": "เปิดใช้",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Erişim Noktası",
|
||||
"agentDetail.access.toggleSurface": "{{name}} erişimini değiştir",
|
||||
"agentDetail.access.webApp.accessUrl": "Erişim URL'si",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Erişim Kontrolü",
|
||||
"agentDetail.access.webApp.actions.customize": "Özel Ön Yüz",
|
||||
"agentDetail.access.webApp.actions.embedded": "Gömülü",
|
||||
"agentDetail.access.webApp.actions.launch": "Başlat",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Точка доступу",
|
||||
"agentDetail.access.toggleSurface": "Перемкнути доступ {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL доступу",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Контроль доступу",
|
||||
"agentDetail.access.webApp.actions.customize": "Користувацький фронтенд",
|
||||
"agentDetail.access.webApp.actions.embedded": "Вбудувати",
|
||||
"agentDetail.access.webApp.actions.launch": "Запустити",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "Điểm truy cập",
|
||||
"agentDetail.access.toggleSurface": "Bật/tắt truy cập {{name}}",
|
||||
"agentDetail.access.webApp.accessUrl": "URL truy cập",
|
||||
"agentDetail.access.webApp.actions.accessControl": "Kiểm soát truy cập",
|
||||
"agentDetail.access.webApp.actions.customize": "Giao diện tùy chỉnh",
|
||||
"agentDetail.access.webApp.actions.embedded": "Nhúng",
|
||||
"agentDetail.access.webApp.actions.launch": "Khởi chạy",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "访问点",
|
||||
"agentDetail.access.toggleSurface": "切换 {{name}} 访问状态",
|
||||
"agentDetail.access.webApp.accessUrl": "访问 URL",
|
||||
"agentDetail.access.webApp.actions.accessControl": "访问控制",
|
||||
"agentDetail.access.webApp.actions.customize": "自定义前端",
|
||||
"agentDetail.access.webApp.actions.embedded": "嵌入",
|
||||
"agentDetail.access.webApp.actions.launch": "启动",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"agentDetail.access.title": "存取點",
|
||||
"agentDetail.access.toggleSurface": "切換 {{name}} 存取狀態",
|
||||
"agentDetail.access.webApp.accessUrl": "存取 URL",
|
||||
"agentDetail.access.webApp.actions.accessControl": "存取控制",
|
||||
"agentDetail.access.webApp.actions.customize": "自訂前端",
|
||||
"agentDetail.access.webApp.actions.embedded": "嵌入",
|
||||
"agentDetail.access.webApp.actions.launch": "啟動",
|
||||
|
||||
@ -16,6 +16,12 @@ export const AccessMode = {
|
||||
|
||||
export type AccessMode = (typeof AccessMode)[keyof typeof AccessMode]
|
||||
|
||||
const accessModes = new Set<string>(Object.values(AccessMode))
|
||||
|
||||
export function isAccessMode(accessMode: string | null | undefined): accessMode is AccessMode {
|
||||
return !!accessMode && accessModes.has(accessMode)
|
||||
}
|
||||
|
||||
export type AccessControlGroup = {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
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 { consoleClient } from '@/service/client'
|
||||
import { useUpdateAccessMode } from '..'
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
enterprise: {
|
||||
webAppAuth: {
|
||||
updateWebAppWhitelistSubjects: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
webAppAuth: {
|
||||
getWebAppAccessMode: {
|
||||
key: vi.fn(() => ['enterprise', 'web-app-auth', 'access-mode']),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
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(consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects).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(
|
||||
consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects,
|
||||
).toHaveBeenCalledWith({
|
||||
body: {
|
||||
appId: 'app-1',
|
||||
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
subjects: [
|
||||
{ subjectId: 'group-1', subjectType: SubjectType.GROUP },
|
||||
{ subjectId: 'account-1', subjectType: SubjectType.ACCOUNT },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -12,6 +12,9 @@ import {
|
||||
const mockSystemFeatures = vi.hoisted(() => ({
|
||||
webappAuthEnabled: false,
|
||||
}))
|
||||
const { mockGetWebAppWhitelistSubjects } = vi.hoisted(() => ({
|
||||
mockGetWebAppWhitelistSubjects: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
get: vi.fn(),
|
||||
@ -22,6 +25,21 @@ vi.mock('@/service/share', () => ({
|
||||
getUserCanAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
webAppAuth: {
|
||||
getWebAppWhitelistSubjects: {
|
||||
queryOptions: ({ input }: { input: { query: { appId?: string } } }) => ({
|
||||
queryKey: ['web-app-whitelist-subjects', input.query.appId],
|
||||
queryFn: () => mockGetWebAppWhitelistSubjects(input),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/client', () => ({
|
||||
systemFeaturesQueryOptions: () =>
|
||||
queryOptions({
|
||||
@ -53,16 +71,44 @@ describe('use-app-access-control', () => {
|
||||
vi.clearAllMocks()
|
||||
mockSystemFeatures.webappAuthEnabled = false
|
||||
vi.mocked(get).mockResolvedValue({ groups: [], members: [] })
|
||||
mockGetWebAppWhitelistSubjects.mockResolvedValue({ groups: [], members: [] })
|
||||
vi.mocked(getUserCanAccess).mockResolvedValue({ result: true })
|
||||
})
|
||||
|
||||
// Queries build the enterprise whitelist endpoints from app and filter inputs.
|
||||
describe('Queries', () => {
|
||||
it('should fetch app whitelist subjects when enabled', async () => {
|
||||
renderHook(() => useAppWhiteListSubjects('app-1', true), { wrapper: createWrapper() })
|
||||
mockGetWebAppWhitelistSubjects.mockResolvedValue({
|
||||
groups: [{ id: 'group-1', name: 'Engineering', groupSize: 3 }],
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
avatar: 'avatar-url',
|
||||
},
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() => useAppWhiteListSubjects('app-1', true), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(get).toHaveBeenCalledWith('/enterprise/webapp/app/subjects?appId=app-1')
|
||||
expect(result.current.data).toEqual({
|
||||
groups: [{ id: 'group-1', name: 'Engineering', groupSize: 3 }],
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'Ada',
|
||||
email: 'ada@example.com',
|
||||
avatar: 'avatar-url',
|
||||
avatarUrl: 'avatar-url',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
expect(mockGetWebAppWhitelistSubjects).toHaveBeenCalledWith({
|
||||
query: { appId: 'app-1' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,21 +1,10 @@
|
||||
import type { AccessControlGroup, AccessMode, Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { consoleClient, consoleQuery } from '../client'
|
||||
import type { AccessControlGroup } from '@/models/access-control'
|
||||
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']
|
||||
@ -42,24 +31,3 @@ export const useSearchForWhiteListCandidates = (
|
||||
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 consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects({
|
||||
body: params,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.key({ type: 'query' }),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [NAME_SPACE, 'app-whitelist-subjects'],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,21 +1,38 @@
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import type { AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { get } from '../base'
|
||||
import { consoleQuery } from '../client'
|
||||
import { getUserCanAccess } from '../share'
|
||||
|
||||
const NAME_SPACE = 'access-control'
|
||||
|
||||
export const useAppWhiteListSubjects = (appId: string | undefined, enabled: boolean) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'app-whitelist-subjects', appId],
|
||||
queryFn: () =>
|
||||
get<{ groups: AccessControlGroup[]; members: AccessControlAccount[] }>(
|
||||
`/enterprise/webapp/app/subjects?appId=${appId}`,
|
||||
),
|
||||
...consoleQuery.enterprise.webAppAuth.getWebAppWhitelistSubjects.queryOptions({
|
||||
input: { query: { appId } },
|
||||
}),
|
||||
enabled: !!appId && enabled,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
select: ({ groups, members }) => ({
|
||||
groups: (groups ?? []).flatMap((group) => {
|
||||
if (!group.id || !group.name || group.groupSize === undefined) return []
|
||||
return [{ id: group.id, name: group.name, groupSize: group.groupSize }]
|
||||
}),
|
||||
members: (members ?? []).flatMap((member) => {
|
||||
if (!member.id || !member.name || !member.email) return []
|
||||
return [
|
||||
{
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
avatar: member.avatar ?? '',
|
||||
avatarUrl: member.avatar ?? '',
|
||||
},
|
||||
]
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -935,6 +935,43 @@ describe('consoleQuery agent mutation defaults', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: oRPC mutation defaults own shared Web app access cache behavior.
|
||||
describe('consoleQuery Web app access mutation defaults', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should invalidate access data and Agent details after updating Web app access', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
const mutationOptions =
|
||||
consoleQuery.enterprise.webAppAuth.updateWebAppWhitelistSubjects.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
{ message: 'updated' },
|
||||
{
|
||||
body: {
|
||||
appId: 'app-1',
|
||||
accessMode: 'private',
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.key(),
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.enterprise.webAppAuth.getWebAppWhitelistSubjects.key(),
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.agent.byAgentId.get.key(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: oRPC mutation defaults own shared tag cache behavior.
|
||||
describe('consoleQuery tag mutation defaults', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@ -833,6 +833,19 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
|
||||
},
|
||||
},
|
||||
enterprise: {
|
||||
webAppAuth: {
|
||||
updateWebAppWhitelistSubjects: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_data, _variables, _result, context) => {
|
||||
return invalidateQueryKeys(context.client, [
|
||||
consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.key(),
|
||||
consoleQuery.enterprise.webAppAuth.getWebAppWhitelistSubjects.key(),
|
||||
consoleQuery.agent.byAgentId.get.key(),
|
||||
])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appInstanceService: {
|
||||
createAppInstance: {
|
||||
mutationOptions: {
|
||||
|
||||
@ -13,7 +13,7 @@ import type {
|
||||
} from '@/models/app'
|
||||
import type { App, AppIconType } from '@/types/app'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AccessMode, isAccessMode } from '@/models/access-control'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { get, post } from './base'
|
||||
@ -29,7 +29,6 @@ export const appDetailQueryKeyPrefix = [NAME_SPACE, 'detail']
|
||||
const useAppFullListKey = [NAME_SPACE, 'full-list']
|
||||
const appIconTypes = new Set<string>(['emoji', 'image', 'link'])
|
||||
const appModes = new Set<string>(Object.values(AppModeEnum))
|
||||
const accessModes = new Set<string>(Object.values(AccessMode))
|
||||
|
||||
function isAppIconType(iconType: string | null | undefined): iconType is AppIconType {
|
||||
return !!iconType && appIconTypes.has(iconType)
|
||||
@ -39,10 +38,6 @@ function isAppMode(mode: string | null | undefined): mode is AppModeEnum {
|
||||
return !!mode && appModes.has(mode)
|
||||
}
|
||||
|
||||
function isAccessMode(accessMode: string | null | undefined): accessMode is AccessMode {
|
||||
return !!accessMode && accessModes.has(accessMode)
|
||||
}
|
||||
|
||||
function normalizeWorkflow(workflow: AppPartial['workflow']): App['workflow'] {
|
||||
if (!workflow) return undefined
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user