From eee8d6cf7b395faeebbbaeb86404c7760db16aca Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:05:29 -0700 Subject: [PATCH] feat(web): add webapp access control to agent access point (#39285) Co-authored-by: yyh --- oxlint-suppressions.json | 8 -- .../__tests__/access-control-item.spec.tsx | 73 ++++++----- .../__tests__/access-control.spec.tsx | 89 ++++++------- .../__tests__/index.spec.tsx | 45 +++++-- .../access-control-item.tsx | 37 +++--- .../app/app-access-control/index.tsx | 38 ++++-- .../__tests__/access-surface-cards.spec.tsx | 123 +++++++++++++++++- .../access/components/web-app-access-card.tsx | 2 + .../web-app-access-control-button.tsx | 63 +++++++++ .../__tests__/agent-prompt-editor.spec.tsx | 65 ++++----- web/i18n/ar-TN/agent-v-2.json | 1 + web/i18n/de-DE/agent-v-2.json | 1 + web/i18n/en-US/agent-v-2.json | 1 + web/i18n/es-ES/agent-v-2.json | 1 + web/i18n/fa-IR/agent-v-2.json | 1 + web/i18n/fr-FR/agent-v-2.json | 1 + web/i18n/hi-IN/agent-v-2.json | 1 + web/i18n/id-ID/agent-v-2.json | 1 + web/i18n/it-IT/agent-v-2.json | 1 + web/i18n/ja-JP/agent-v-2.json | 1 + web/i18n/ko-KR/agent-v-2.json | 1 + web/i18n/nl-NL/agent-v-2.json | 1 + web/i18n/pl-PL/agent-v-2.json | 1 + web/i18n/pt-BR/agent-v-2.json | 1 + web/i18n/ro-RO/agent-v-2.json | 1 + web/i18n/ru-RU/agent-v-2.json | 1 + web/i18n/sl-SI/agent-v-2.json | 1 + web/i18n/th-TH/agent-v-2.json | 1 + web/i18n/tr-TR/agent-v-2.json | 1 + web/i18n/uk-UA/agent-v-2.json | 1 + web/i18n/vi-VN/agent-v-2.json | 1 + web/i18n/zh-Hans/agent-v-2.json | 1 + web/i18n/zh-Hant/agent-v-2.json | 1 + web/models/access-control.ts | 6 + .../access-control/__tests__/index.spec.tsx | 78 ----------- .../__tests__/use-app-access-control.spec.tsx | 50 ++++++- web/service/access-control/index.ts | 34 +---- .../access-control/use-app-access-control.ts | 29 ++++- web/service/client.spec.ts | 37 ++++++ web/service/client.ts | 13 ++ web/service/use-apps.ts | 7 +- 41 files changed, 524 insertions(+), 296 deletions(-) create mode 100644 web/features/agent-v2/agent-detail/access/components/web-app-access-control-button.tsx delete mode 100644 web/service/access-control/__tests__/index.spec.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 985a47ef1c3..c3253808473 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 diff --git a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx index b1a862a13c9..16b389155dd 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx @@ -1,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(initialValue) + + return ( + aria-label="Access" value={value} onValueChange={setValue}> + Organization Only + Anyone + + ) + } + + it('should expose a single-select radio group and update the checked option', async () => { + const user = userEvent.setup() + render() + + 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( - - Organization Only - , - ) + it('should support arrow-key selection between options', async () => { + const user = userEvent.setup() + render() - 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( - - Organization Only - , - ) - - const option = screen.getByText('Organization Only').parentElement as HTMLElement - expect(option).toHaveClass('border-components-option-card-option-selected-border') + expect(anyone).toBeChecked() }) }) diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 5f873d951ed..3e557b5f958 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -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() + 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('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( - - Organization Only - , - ) - - 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( - - Organization Only - , - ) - - 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() diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index dcc8a05fb73..d273ddb0ad8 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -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() + + const organization = screen.getByRole('radio', { + name: 'app.accessControlDialog.accessItems.organization', + }) + await user.click(organization) + expect(organization).toBeChecked() + + rerender() + + expect(organization).toBeChecked() + }) }) diff --git a/web/app/components/app/app-access-control/access-control-item.tsx b/web/app/components/app/app-access-control/access-control-item.tsx index 5fdba89788a..446fb67ca6e 100644 --- a/web/app/components/app/app-access-control/access-control-item.tsx +++ b/web/app/components/app/app-access-control/access-control-item.tsx @@ -1,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 = ({ type, children }) => { - const currentMenu = useAccessControlStore((s) => s.currentMenu) - const setCurrentMenu = useAccessControlStore((s) => s.setCurrentMenu) - if (currentMenu !== type) { - return ( -
setCurrentMenu(type)} - > - {children} -
- ) - } - +export default function AccessControlItem({ type, children }: AccessControlItemProps) { return ( -
+ + value={type} + render={
} + className={cn( + 'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors', + 'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover', + 'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', + 'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:inset-ring-[0.5px] data-checked:inset-ring-components-option-card-option-selected-border', + )} + > {children} -
+ ) } - -AccessControlItem.displayName = 'AccessControlItem' - -export default AccessControlItem diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 8cdd7688fed..f443b80fa72 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -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 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[] - } = { appId: app.id, accessMode: currentMenu } + } = { appId, accessMode: currentMenu } if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) { const subjects: Pick[] = [] 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 (
@@ -77,9 +82,14 @@ export default function AccessControl(props: AccessControlProps) { {t(($) => $['accessControlDialog.description'], { ns: 'app' })}
-
+ + value={currentMenu} + onValueChange={setCurrentMenu} + className="flex flex-col items-stretch gap-y-1 px-6 pb-3" + aria-labelledby={accessControlOptionsLabelId} + >
-

+

{t(($) => $['accessControlDialog.accessLabel'], { ns: 'app' })}

@@ -115,7 +125,7 @@ export default function AccessControl(props: AccessControlProps) {

-
+
+ {settingsAppInfo && ( 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 ( + <> + + {showAccessControl && ( + setShowAccessControl(false)} + onConfirm={() => setShowAccessControl(false)} + /> + )} + + ) +} diff --git a/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx index c194d4bd05a..f4376642ad1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.spec.tsx @@ -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( - - - , - ) + 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 () => { diff --git a/web/i18n/ar-TN/agent-v-2.json b/web/i18n/ar-TN/agent-v-2.json index 4bd01e39c14..00665d2829c 100644 --- a/web/i18n/ar-TN/agent-v-2.json +++ b/web/i18n/ar-TN/agent-v-2.json @@ -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": "تشغيل", diff --git a/web/i18n/de-DE/agent-v-2.json b/web/i18n/de-DE/agent-v-2.json index a3337be5d2d..dae4be08ce4 100644 --- a/web/i18n/de-DE/agent-v-2.json +++ b/web/i18n/de-DE/agent-v-2.json @@ -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", diff --git a/web/i18n/en-US/agent-v-2.json b/web/i18n/en-US/agent-v-2.json index b57a162e062..77c0acc4edb 100644 --- a/web/i18n/en-US/agent-v-2.json +++ b/web/i18n/en-US/agent-v-2.json @@ -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", diff --git a/web/i18n/es-ES/agent-v-2.json b/web/i18n/es-ES/agent-v-2.json index 1ba0724001c..40f9b46d3fd 100644 --- a/web/i18n/es-ES/agent-v-2.json +++ b/web/i18n/es-ES/agent-v-2.json @@ -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", diff --git a/web/i18n/fa-IR/agent-v-2.json b/web/i18n/fa-IR/agent-v-2.json index ac53487e6f3..67c5868d1b6 100644 --- a/web/i18n/fa-IR/agent-v-2.json +++ b/web/i18n/fa-IR/agent-v-2.json @@ -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": "راه‌اندازی", diff --git a/web/i18n/fr-FR/agent-v-2.json b/web/i18n/fr-FR/agent-v-2.json index 45db2d6af54..2738979f675 100644 --- a/web/i18n/fr-FR/agent-v-2.json +++ b/web/i18n/fr-FR/agent-v-2.json @@ -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", diff --git a/web/i18n/hi-IN/agent-v-2.json b/web/i18n/hi-IN/agent-v-2.json index 7f0a398d65f..614e621514a 100644 --- a/web/i18n/hi-IN/agent-v-2.json +++ b/web/i18n/hi-IN/agent-v-2.json @@ -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": "लॉन्च करें", diff --git a/web/i18n/id-ID/agent-v-2.json b/web/i18n/id-ID/agent-v-2.json index 2b25cf352d2..91cf778dfcc 100644 --- a/web/i18n/id-ID/agent-v-2.json +++ b/web/i18n/id-ID/agent-v-2.json @@ -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", diff --git a/web/i18n/it-IT/agent-v-2.json b/web/i18n/it-IT/agent-v-2.json index 426a4f881ce..698d3662f46 100644 --- a/web/i18n/it-IT/agent-v-2.json +++ b/web/i18n/it-IT/agent-v-2.json @@ -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", diff --git a/web/i18n/ja-JP/agent-v-2.json b/web/i18n/ja-JP/agent-v-2.json index d7d3d38bd31..87f028d24d3 100644 --- a/web/i18n/ja-JP/agent-v-2.json +++ b/web/i18n/ja-JP/agent-v-2.json @@ -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": "起動", diff --git a/web/i18n/ko-KR/agent-v-2.json b/web/i18n/ko-KR/agent-v-2.json index 9e8d211c739..98c63dbcf7e 100644 --- a/web/i18n/ko-KR/agent-v-2.json +++ b/web/i18n/ko-KR/agent-v-2.json @@ -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": "실행", diff --git a/web/i18n/nl-NL/agent-v-2.json b/web/i18n/nl-NL/agent-v-2.json index 01dee2bcd1e..cfabdcdb21e 100644 --- a/web/i18n/nl-NL/agent-v-2.json +++ b/web/i18n/nl-NL/agent-v-2.json @@ -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", diff --git a/web/i18n/pl-PL/agent-v-2.json b/web/i18n/pl-PL/agent-v-2.json index 8ee2fd81ac9..4eb8b091d46 100644 --- a/web/i18n/pl-PL/agent-v-2.json +++ b/web/i18n/pl-PL/agent-v-2.json @@ -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", diff --git a/web/i18n/pt-BR/agent-v-2.json b/web/i18n/pt-BR/agent-v-2.json index 61b21209e72..c47a9b90775 100644 --- a/web/i18n/pt-BR/agent-v-2.json +++ b/web/i18n/pt-BR/agent-v-2.json @@ -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", diff --git a/web/i18n/ro-RO/agent-v-2.json b/web/i18n/ro-RO/agent-v-2.json index 1ecc78cd062..d3e2966defb 100644 --- a/web/i18n/ro-RO/agent-v-2.json +++ b/web/i18n/ro-RO/agent-v-2.json @@ -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ă", diff --git a/web/i18n/ru-RU/agent-v-2.json b/web/i18n/ru-RU/agent-v-2.json index 6016dfaacec..412ee19d2d8 100644 --- a/web/i18n/ru-RU/agent-v-2.json +++ b/web/i18n/ru-RU/agent-v-2.json @@ -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": "Запустить", diff --git a/web/i18n/sl-SI/agent-v-2.json b/web/i18n/sl-SI/agent-v-2.json index f1b7cdb7ae8..67d666b4b8c 100644 --- a/web/i18n/sl-SI/agent-v-2.json +++ b/web/i18n/sl-SI/agent-v-2.json @@ -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", diff --git a/web/i18n/th-TH/agent-v-2.json b/web/i18n/th-TH/agent-v-2.json index 2f3c891879f..7e8c5092ed8 100644 --- a/web/i18n/th-TH/agent-v-2.json +++ b/web/i18n/th-TH/agent-v-2.json @@ -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": "เปิดใช้", diff --git a/web/i18n/tr-TR/agent-v-2.json b/web/i18n/tr-TR/agent-v-2.json index 06c2ca75f28..b5beb941bed 100644 --- a/web/i18n/tr-TR/agent-v-2.json +++ b/web/i18n/tr-TR/agent-v-2.json @@ -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", diff --git a/web/i18n/uk-UA/agent-v-2.json b/web/i18n/uk-UA/agent-v-2.json index bc0d8bfdb2f..8f9268204fd 100644 --- a/web/i18n/uk-UA/agent-v-2.json +++ b/web/i18n/uk-UA/agent-v-2.json @@ -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": "Запустити", diff --git a/web/i18n/vi-VN/agent-v-2.json b/web/i18n/vi-VN/agent-v-2.json index 0076196f5b8..ccc35102137 100644 --- a/web/i18n/vi-VN/agent-v-2.json +++ b/web/i18n/vi-VN/agent-v-2.json @@ -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", diff --git a/web/i18n/zh-Hans/agent-v-2.json b/web/i18n/zh-Hans/agent-v-2.json index a4686e1c6fa..d4550111428 100644 --- a/web/i18n/zh-Hans/agent-v-2.json +++ b/web/i18n/zh-Hans/agent-v-2.json @@ -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": "启动", diff --git a/web/i18n/zh-Hant/agent-v-2.json b/web/i18n/zh-Hant/agent-v-2.json index e62261c03cc..3fdbb679ee6 100644 --- a/web/i18n/zh-Hant/agent-v-2.json +++ b/web/i18n/zh-Hant/agent-v-2.json @@ -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": "啟動", diff --git a/web/models/access-control.ts b/web/models/access-control.ts index c0a179bc677..a96d2eb9c90 100644 --- a/web/models/access-control.ts +++ b/web/models/access-control.ts @@ -16,6 +16,12 @@ export const AccessMode = { export type AccessMode = (typeof AccessMode)[keyof typeof AccessMode] +const accessModes = new Set(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 diff --git a/web/service/access-control/__tests__/index.spec.tsx b/web/service/access-control/__tests__/index.spec.tsx deleted file mode 100644 index 086fc7e1354..00000000000 --- a/web/service/access-control/__tests__/index.spec.tsx +++ /dev/null @@ -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 }) => ( - {children} - ) -} - -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 }, - ], - }, - }) - }) - }) - }) -}) diff --git a/web/service/access-control/__tests__/use-app-access-control.spec.tsx b/web/service/access-control/__tests__/use-app-access-control.spec.tsx index 46fed31a66e..95c43ac24c9 100644 --- a/web/service/access-control/__tests__/use-app-access-control.spec.tsx +++ b/web/service/access-control/__tests__/use-app-access-control.spec.tsx @@ -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' }, }) }) diff --git a/web/service/access-control/index.ts b/web/service/access-control/index.ts index 601f3d6ded4..d095a0e9cb4 100644 --- a/web/service/access-control/index.ts +++ b/web/service/access-control/index.ts @@ -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[] - 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'], - }) - }, - }) -} diff --git a/web/service/access-control/use-app-access-control.ts b/web/service/access-control/use-app-access-control.ts index 9c0ab7de260..513d61e795a 100644 --- a/web/service/access-control/use-app-access-control.ts +++ b/web/service/access-control/use-app-access-control.ts @@ -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 ?? '', + }, + ] + }), + }), }) } diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index 97d5e1ed59c..2c00ba8560d 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -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(() => { diff --git a/web/service/client.ts b/web/service/client.ts index 3443010c1c5..f72b5798f21 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -833,6 +833,19 @@ export const consoleQuery: RouterUtils = 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: { diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index 585145f21ba..405d1cf8a8b 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -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(['emoji', 'image', 'link']) const appModes = new Set(Object.values(AppModeEnum)) -const accessModes = new Set(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