feat(web): add webapp access control to agent access point (#39285)

Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Xiyuan Chen 2026-07-20 01:05:29 -07:00 committed by GitHub
parent 11016563b1
commit eee8d6cf7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 524 additions and 296 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "تشغيل",

View File

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

View File

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

View File

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

View File

@ -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": "راه‌اندازی",

View File

@ -14,6 +14,7 @@
"agentDetail.access.title": "Point daccès",
"agentDetail.access.toggleSurface": "Basculer laccès de {{name}}",
"agentDetail.access.webApp.accessUrl": "URL daccè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",

View File

@ -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": "लॉन्च करें",

View File

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

View File

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

View File

@ -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": "起動",

View File

@ -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": "실행",

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "Запустить",

View File

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

View File

@ -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": "เปิดใช้",

View File

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

View File

@ -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": "Запустити",

View File

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

View File

@ -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": "启动",

View File

@ -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": "啟動",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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