From 1e17c0f314affb965d142544390b11c7394e48c2 Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 23 Jun 2026 18:57:55 +0800 Subject: [PATCH] feat: access point settings --- .../__tests__/access-surface-card.spec.tsx | 68 +++++ .../__tests__/access-surface-cards.spec.tsx | 281 ++++++++++++++++++ .../access/components/access-surface-card.tsx | 124 ++++++++ .../access/components/agent-api-key-modal.tsx | 251 ++++++++++++++++ .../components/service-api-access-card.tsx | 123 ++++++++ .../access/components/web-app-access-card.tsx | 190 ++++++++++++ .../agent-v2/agent-detail/access/page.tsx | 234 +-------------- 7 files changed, 1041 insertions(+), 230 deletions(-) create mode 100644 web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-card.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx create mode 100644 web/features/agent-v2/agent-detail/access/components/agent-api-key-modal.tsx create mode 100644 web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx create mode 100644 web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-card.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-card.spec.tsx new file mode 100644 index 00000000000..d7b95a5aa84 --- /dev/null +++ b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-card.spec.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AccessSurfaceCard } from '../access-surface-card' + +const mockCopy = vi.fn() +let mockCopied = false + +vi.mock('foxact/use-clipboard', () => ({ + useClipboard: () => ({ + copied: mockCopied, + copy: mockCopy, + }), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +describe('AccessSurfaceCard', () => { + beforeEach(() => { + mockCopied = false + vi.clearAllMocks() + }) + + describe('Copy feedback', () => { + it('should copy the endpoint and render copied state from the clipboard hook', async () => { + const user = userEvent.setup() + const { rerender } = render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Copy access URL' })) + + expect(mockCopy).toHaveBeenCalledWith('https://chat.example.test/agent/token') + + mockCopied = true + rerender( + + + , + ) + + expect(screen.getByRole('button', { name: 'common.operation.copied' })).toBeInTheDocument() + }) + }) +}) diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx new file mode 100644 index 00000000000..eab2bfe59a0 --- /dev/null +++ b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx @@ -0,0 +1,281 @@ +import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' +import type React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ServiceApiAccessCard } from '../service-api-access-card' +import { WebAppAccessCard } from '../web-app-access-card' + +const mocks = vi.hoisted(() => ({ + apiAccessQueryFn: vi.fn(), + apiKeysQueryFn: vi.fn(), + siteEnableMutation: vi.fn(), + siteAccessTokenResetMutation: vi.fn(), + apiEnableMutation: vi.fn(), + createApiKeyMutation: vi.fn(), + deleteApiKeyMutation: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.test${path}`, +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: (value: number) => `formatted-${value}`, + }), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + byAppId: { + siteEnable: { + post: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.siteEnableMutation, + ...options, + }), + }, + }, + site: { + accessTokenReset: { + post: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.siteAccessTokenResetMutation, + ...options, + }), + }, + }, + }, + }, + }, + agent: { + byAgentId: { + get: { + queryKey: ({ input }: { input: { params: { agent_id: string } } }) => ['agent-detail', input.params.agent_id], + }, + apiAccess: { + get: { + queryKey: ({ input }: { input: { params: { agent_id: string } } }) => ['agent-api-access', input.params.agent_id], + queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({ + queryKey: ['agent-api-access', input.params.agent_id], + queryFn: () => mocks.apiAccessQueryFn(input), + }), + }, + }, + apiEnable: { + post: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.apiEnableMutation, + ...options, + }), + }, + }, + apiKeys: { + get: { + queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({ + queryKey: ['agent-api-keys', input.params.agent_id], + queryFn: () => mocks.apiKeysQueryFn(input), + }), + }, + post: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.createApiKeyMutation, + ...options, + }), + }, + byApiKeyId: { + delete: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.deleteApiKeyMutation, + ...options, + }), + }, + }, + }, + }, + }, + }, +})) + +function createAgent(overrides: Partial = {}): AgentAppDetailWithSite { + return { + enable_api: true, + enable_site: true, + icon_url: null, + id: 'agent-1', + mode: 'agent', + name: 'Support Agent', + app_id: 'app-1', + access_mode: 'sso_verified', + site: { + access_token: 'site-token', + app_base_url: 'https://chat.example.test', + chat_color_theme_inverted: false, + default_language: 'en-US', + icon_url: null, + show_workflow_steps: false, + title: 'Support Agent', + use_icon_as_answer_icon: false, + } as NonNullable & { + access_token: string + app_base_url: string + }, + ...overrides, + } +} + +function renderWithQueryClient(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + + render( + + {ui} + , + ) + + return queryClient +} + +describe('Agent access surface cards', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Web app access', () => { + it('should render the backend web app URL and toggle site status through the backing app id', async () => { + const user = userEvent.setup() + mocks.siteEnableMutation.mockResolvedValueOnce({ enable_site: false }) + + renderWithQueryClient( + , + ) + + expect(screen.getByText('https://chat.example.test/agent/site-token')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'agentV2.agentDetail.access.webApp.actions.launch' })).toHaveAttribute('href', 'https://chat.example.test/agent/site-token') + expect(screen.getByText('agentV2.agentDetail.access.webApp.ssoEnabled')).toBeInTheDocument() + + await user.click(screen.getByRole('switch', { name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.webApp.title"}' })) + + await waitFor(() => { + expect(mocks.siteEnableMutation.mock.calls[0]?.[0]).toEqual({ + params: { + app_id: 'app-1', + }, + body: { + enable_site: false, + }, + }) + }) + }) + }) + + describe('Service API access', () => { + it('should render service API data and toggle Agent API status through the generated Agent endpoint', async () => { + const user = userEvent.setup() + mocks.apiAccessQueryFn.mockResolvedValueOnce({ + api_key_count: 2, + enabled: true, + service_api_base_url: 'https://api.example.test/v1', + }) + mocks.apiEnableMutation.mockResolvedValueOnce({ + api_key_count: 2, + enabled: false, + service_api_base_url: 'https://api.example.test/v1', + }) + + renderWithQueryClient() + + expect(await screen.findByText('https://api.example.test/v1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + + await user.click(screen.getByRole('switch', { name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.serviceApi.title"}' })) + + await waitFor(() => { + expect(mocks.apiEnableMutation.mock.calls[0]?.[0]).toEqual({ + params: { + agent_id: 'agent-1', + }, + body: { + enable_api: false, + }, + }) + }) + }) + + it('should manage API keys with the Agent API key endpoints', async () => { + const user = userEvent.setup() + mocks.apiAccessQueryFn.mockResolvedValue({ + api_key_count: 1, + enabled: true, + service_api_base_url: 'https://api.example.test/v1', + }) + mocks.apiKeysQueryFn.mockResolvedValue({ + data: [ + { + created_at: 1781660000, + id: 'key-1', + last_used_at: null, + token: 'app-existing-secret-key-token', + type: 'app', + }, + ], + }) + mocks.createApiKeyMutation.mockResolvedValueOnce({ + created_at: 1781660100, + id: 'key-2', + last_used_at: null, + token: 'app-new-secret-key-token', + type: 'app', + }) + mocks.deleteApiKeyMutation.mockResolvedValueOnce(undefined) + + renderWithQueryClient() + + await user.click(await screen.findByRole('button', { name: /agentV2\.agentDetail\.access\.serviceApi\.actions\.apiKey/ })) + + const dialog = await screen.findByRole('dialog', { name: 'appApi.apiKeyModal.apiSecretKey' }) + expect(await within(dialog).findByText('app...ing-secret-key-token')).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' })) + + await waitFor(() => { + expect(mocks.createApiKeyMutation.mock.calls[0]?.[0]).toEqual({ + params: { + agent_id: 'agent-1', + }, + }) + }) + expect(await within(dialog).findByText('app-new-secret-key-token')).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' })) + await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(mocks.deleteApiKeyMutation.mock.calls[0]?.[0]).toEqual({ + params: { + agent_id: 'agent-1', + api_key_id: 'key-1', + }, + }) + }) + }) + }) +}) diff --git a/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx b/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx new file mode 100644 index 00000000000..146a0812077 --- /dev/null +++ b/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx @@ -0,0 +1,124 @@ +'use client' + +import type { ReactNode } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { StatusDot } from '@langgenius/dify-ui/status-dot' +import { Switch } from '@langgenius/dify-ui/switch' +import { toast } from '@langgenius/dify-ui/toast' +import { useClipboard } from 'foxact/use-clipboard' +import { useTranslation } from 'react-i18next' + +export type AccessSurfaceCardProps = { + title: string + icon: string + iconClassName: string + endpointLabel: string + endpoint: string + enabled: boolean + onEnabledChange: (enabled: boolean) => void + copyLabel: string + children: ReactNode + badge?: ReactNode + endpointActions?: ReactNode + disabled?: boolean + busy?: boolean +} + +export const accessSurfaceActionClassName = 'inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 text-[13px] leading-4 font-medium text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' + +export function AccessSurfaceCard({ + title, + icon, + iconClassName, + endpointLabel, + endpoint, + enabled, + onEnabledChange, + copyLabel, + children, + badge, + endpointActions, + disabled = false, + busy = false, +}: AccessSurfaceCardProps) { + const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') + const { copied, copy } = useClipboard({ + timeout: 2000, + onCopyError: () => { + toast.error(t('agentDetail.access.copyFailed')) + }, + }) + const canCopyEndpoint = Boolean(endpoint) + const switchDisabled = disabled || busy + + const handleCopyEndpoint = () => { + if (!canCopyEndpoint) + return + + void copy(endpoint) + } + + return ( +
+
+
+
+ + + +

+ {title} +

+ {badge} +
+ +
+ + + {t(enabled ? 'agentDetail.access.status.inService' : 'agentDetail.access.status.outOfService')} + + +
+
+ +
+
+ {endpointLabel} +
+
+ + {endpoint || t('agentDetail.access.workflow.notAvailable')} + + + {endpointActions} +
+
+
+ +
+ {children} +
+
+ ) +} diff --git a/web/features/agent-v2/agent-detail/access/components/agent-api-key-modal.tsx b/web/features/agent-v2/agent-detail/access/components/agent-api-key-modal.tsx new file mode 100644 index 00000000000..834f35f2be4 --- /dev/null +++ b/web/features/agent-v2/agent-detail/access/components/agent-api-key-modal.tsx @@ -0,0 +1,251 @@ +'use client' + +import type { ApiKeyItem } from '@dify/contracts/api/console/agent/types.gen' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import CopyFeedback from '@/app/components/base/copy-feedback' +import useTimestamp from '@/hooks/use-timestamp' +import { consoleQuery } from '@/service/client' + +export function AgentApiKeyModal({ + agentId, + open, + onOpenChange, +}: { + agentId: string + open: boolean + onOpenChange: (open: boolean) => void +}) { + const { t } = useTranslation('appApi') + const { t: tCommon } = useTranslation('common') + const { formatTime } = useTimestamp() + const queryClient = useQueryClient() + const [newKey, setNewKey] = useState(null) + const [apiKeyToDelete, setApiKeyToDelete] = useState(null) + const apiKeysQueryOptions = consoleQuery.agent.byAgentId.apiKeys.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, + }, + }) + const apiKeysQuery = useQuery({ + ...apiKeysQueryOptions, + enabled: open, + }) + const createApiKeyMutation = useMutation(consoleQuery.agent.byAgentId.apiKeys.post.mutationOptions({ + onSuccess: (createdKey) => { + setNewKey(createdKey) + queryClient.invalidateQueries({ queryKey: apiKeysQueryOptions.queryKey }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.apiAccess.get.queryKey({ + input: { + params: { + agent_id: agentId, + }, + }, + }), + }) + toast.success(tCommon('actionMsg.modifiedSuccessfully')) + }, + onError: () => { + toast.error(tCommon('actionMsg.modifiedUnsuccessfully')) + }, + })) + const deleteApiKeyMutation = useMutation(consoleQuery.agent.byAgentId.apiKeys.byApiKeyId.delete.mutationOptions({ + onSuccess: () => { + setApiKeyToDelete(null) + queryClient.invalidateQueries({ queryKey: apiKeysQueryOptions.queryKey }) + queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.apiAccess.get.queryKey({ + input: { + params: { + agent_id: agentId, + }, + }, + }), + }) + toast.success(tCommon('actionMsg.modifiedSuccessfully')) + }, + onError: () => { + toast.error(tCommon('actionMsg.modifiedUnsuccessfully')) + }, + })) + const apiKeys = apiKeysQuery.data?.data ?? [] + const isCreating = createApiKeyMutation.isPending + const isDeleting = deleteApiKeyMutation.isPending + + function handleCreateApiKey() { + createApiKeyMutation.mutate({ + params: { + agent_id: agentId, + }, + }) + } + + function handleDeleteApiKey() { + if (!apiKeyToDelete) + return + + deleteApiKeyMutation.mutate({ + params: { + agent_id: agentId, + api_key_id: apiKeyToDelete.id, + }, + }) + } + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) { + setNewKey(null) + setApiKeyToDelete(null) + } + + onOpenChange(nextOpen) + } + + return ( + <> + + + + + {t('apiKeyModal.apiSecretKey')} + + + {t('apiKeyModal.apiSecretKeyTips')} + + + {newKey && ( +
+
+ {t('apiKeyModal.generateTips')} +
+
+ + {newKey.token} + + +
+
+ )} + +
+
+
{t('apiKeyModal.secretKey')}
+
{t('apiKeyModal.created')}
+
{t('apiKeyModal.lastUsed')}
+
+
+
+ {apiKeysQuery.isPending && ( +
+ {t('loading')} +
+ )} + {apiKeysQuery.isError && ( +
+ {tCommon('api.actionFailed')} + +
+ )} + {apiKeysQuery.isSuccess && apiKeys.length === 0 && ( +
+ {tCommon('noData')} +
+ )} + {apiKeysQuery.isSuccess && apiKeys.map(apiKey => ( +
+
+ {maskApiKey(apiKey.token)} +
+
+ {apiKey.created_at ? formatTime(apiKey.created_at, t('dateTimeFormat', { ns: 'appLog' })) : t('never')} +
+
+ {apiKey.last_used_at ? formatTime(apiKey.last_used_at, t('dateTimeFormat', { ns: 'appLog' })) : t('never')} +
+
+ + +
+
+ ))} +
+
+ +
+ +
+ +
+ + { + if (!nextOpen) + setApiKeyToDelete(null) + }} + > + +
+ + {t('actionMsg.deleteConfirmTitle')} + + + {t('actionMsg.deleteConfirmTips')} + +
+ + + {tCommon('operation.cancel')} + + + {tCommon('operation.confirm')} + + +
+
+ + ) +} + +function maskApiKey(token: string) { + if (token.length <= 24) + return token + + return `${token.slice(0, 3)}...${token.slice(-20)}` +} diff --git a/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx b/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx new file mode 100644 index 00000000000..28d3b72cd13 --- /dev/null +++ b/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx @@ -0,0 +1,123 @@ +'use client' + +import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDocLink } from '@/context/i18n' +import { consoleQuery } from '@/service/client' +import { accessSurfaceActionClassName, AccessSurfaceCard } from './access-surface-card' +import { AgentApiKeyModal } from './agent-api-key-modal' + +export function ServiceApiAccessCard({ + agentId, +}: { + agentId: string +}) { + const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') + const docLink = useDocLink() + const queryClient = useQueryClient() + const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false) + const apiAccessQueryOptions = consoleQuery.agent.byAgentId.apiAccess.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, + }, + }) + const apiAccessQuery = useQuery(apiAccessQueryOptions) + const apiAccess = apiAccessQuery.data + const toggleServiceApiMutation = useMutation(consoleQuery.agent.byAgentId.apiEnable.post.mutationOptions({ + onSuccess: (updatedApiAccess, variables) => { + queryClient.setQueryData(apiAccessQueryOptions.queryKey, updatedApiAccess) + queryClient.setQueryData( + consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), + agentDetail => agentDetail + ? { + ...agentDetail, + enable_api: variables.body.enable_api, + } + : agentDetail, + ) + toast.success(tCommon('actionMsg.modifiedSuccessfully')) + }, + onError: () => { + toast.error(tCommon('actionMsg.modifiedUnsuccessfully')) + }, + })) + const isBusy = apiAccessQuery.isPending || toggleServiceApiMutation.isPending + + function handleEnabledChange(enabled: boolean) { + toggleServiceApiMutation.mutate({ + params: { + agent_id: agentId, + }, + body: { + enable_api: enabled, + }, + }) + } + + return ( + <> + + + + + {t('agentDetail.access.serviceApi.actions.apiReference')} + + {apiAccessQuery.isError && ( + + )} + + + + + ) +} diff --git a/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx b/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx new file mode 100644 index 00000000000..39d583ff525 --- /dev/null +++ b/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx @@ -0,0 +1,190 @@ +'use client' + +import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import ShareQRCode from '@/app/components/base/qrcode' +import { AccessMode } from '@/models/access-control' +import { consoleQuery } from '@/service/client' +import { accessSurfaceActionClassName, AccessSurfaceCard } from './access-surface-card' + +type AgentWebAppSite = NonNullable & { + access_token?: string | null + app_base_url?: string | null + code?: string | null +} + +export function WebAppAccessCard({ + agent, + agentId, + isLoading, +}: { + agent?: AgentAppDetailWithSite + agentId: string + isLoading: boolean +}) { + const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') + const queryClient = useQueryClient() + const appId = agent?.app_id + const webAppUrl = getAgentWebAppUrl(agent) + const isEnabled = Boolean(agent?.enable_site) + const canManageWebApp = Boolean(appId) + const showSsoBadge = agent?.access_mode === AccessMode.EXTERNAL_MEMBERS + const toggleSiteMutation = useMutation(consoleQuery.apps.byAppId.siteEnable.post.mutationOptions({ + onSuccess: (_updatedApp, variables) => { + queryClient.setQueryData( + consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), + agentDetail => agentDetail + ? { + ...agentDetail, + enable_site: variables.body.enable_site, + } + : agentDetail, + ) + toast.success(tCommon('actionMsg.modifiedSuccessfully')) + }, + onError: () => { + toast.error(tCommon('actionMsg.modifiedUnsuccessfully')) + }, + })) + const resetAccessTokenMutation = useMutation(consoleQuery.apps.byAppId.site.accessTokenReset.post.mutationOptions({ + onSuccess: (site) => { + queryClient.setQueryData( + consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), + (agentDetail) => { + if (!agentDetail) + return agentDetail + + return { + ...agentDetail, + site: { + ...agentDetail.site, + ...site, + access_token: site.code, + } as AgentWebAppSite, + } + }, + ) + toast.success(tCommon('actionMsg.generatedSuccessfully')) + }, + onError: () => { + toast.error(tCommon('actionMsg.generatedUnsuccessfully')) + }, + })) + const isBusy = toggleSiteMutation.isPending || resetAccessTokenMutation.isPending + + function handleEnabledChange(enabled: boolean) { + if (!appId) + return + + toggleSiteMutation.mutate({ + params: { + app_id: appId, + }, + body: { + enable_site: enabled, + }, + }) + } + + function handleRefreshUrl() { + if (!appId) + return + + resetAccessTokenMutation.mutate({ + params: { + app_id: appId, + }, + }) + } + + return ( + : undefined} + endpointActions={webAppUrl + ? ( + <> + + + + + ) + : undefined} + disabled={isLoading || !canManageWebApp} + busy={isBusy} + > + {webAppUrl && isEnabled + ? ( + + + {t('agentDetail.access.webApp.actions.launch')} + + ) + : ( + + )} + + + + + ) +} + +function getAgentWebAppUrl(agent?: AgentAppDetailWithSite) { + const site = agent?.site as AgentWebAppSite | null | undefined + const token = site?.access_token ?? site?.code + if (!token) + return '' + + const baseUrl = site?.app_base_url || (typeof window === 'undefined' ? '' : window.location.origin) + return `${baseUrl.replace(/\/$/, '')}/agent/${token}` +} + +function SsoBadge() { + const { t } = useTranslation('agentV2') + + return ( + + + {t('agentDetail.access.webApp.ssoEnabled')} + + ) +} diff --git a/web/features/agent-v2/agent-detail/access/page.tsx b/web/features/agent-v2/agent-detail/access/page.tsx index b209ba9547a..7e1d653fb70 100644 --- a/web/features/agent-v2/agent-detail/access/page.tsx +++ b/web/features/agent-v2/agent-detail/access/page.tsx @@ -1,57 +1,18 @@ 'use client' -import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' -import type { ReactNode } from 'react' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' import { ScrollArea } from '@langgenius/dify-ui/scroll-area' -import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { Switch } from '@langgenius/dify-ui/switch' -import { toast } from '@langgenius/dify-ui/toast' import { useQuery } from '@tanstack/react-query' -import { useClipboard } from 'foxact/use-clipboard' -import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' import { consoleQuery } from '@/service/client' +import { ServiceApiAccessCard } from './components/service-api-access-card' +import { WebAppAccessCard } from './components/web-app-access-card' import { WorkflowReferencesTable } from './components/workflow-references-table' type AgentAccessPageProps = { agentId: string } -const serviceApiEndpoint = 'https://api.dify.ai/v1' - -type AgentWebAppSite = NonNullable & { - access_token?: string | null - app_base_url?: string | null - code?: string | null -} - -type AccessSurfaceCardProps = { - title: string - icon: string - iconClassName: string - endpointLabel: string - endpoint: string - enabled: boolean - onEnabledChange: (enabled: boolean) => void - copyLabel: string - children: ReactNode - badge?: ReactNode - onUnavailableAction: () => void -} - -const getAgentWebAppUrl = (agent?: AgentAppDetailWithSite) => { - const site = agent?.site as AgentWebAppSite | null | undefined - const token = site?.access_token ?? site?.code - if (!token) - return '' - - const baseUrl = site?.app_base_url || (typeof window === 'undefined' ? '' : window.location.origin) - return `${baseUrl.replace(/\/$/, '')}/agent/${token}` -} - export function AgentAccessPage({ agentId, }: AgentAccessPageProps) { @@ -64,12 +25,6 @@ export function AgentAccessPage({ }, }, })) - const [isWebAppEnabled, setIsWebAppEnabled] = useState(true) - const [isServiceApiEnabled, setIsServiceApiEnabled] = useState(true) - const webAppUrl = getAgentWebAppUrl(agentQuery.data) - const handleUnavailableAction = () => { - toast.info(t('agentDetail.access.actionUnavailable')) - } return (
- } - onUnavailableAction={handleUnavailableAction} - > - - - - - - - - - - + +
@@ -188,121 +80,3 @@ export function AgentAccessPage({
) } - -function AccessSurfaceCard({ - title, - icon, - iconClassName, - endpointLabel, - endpoint, - enabled, - onEnabledChange, - copyLabel, - children, - badge, - onUnavailableAction, -}: AccessSurfaceCardProps) { - const { t } = useTranslation('agentV2') - const { copy } = useClipboard({ - onCopyError: () => { - toast.error(t('agentDetail.access.copyFailed')) - }, - }) - - const handleCopyEndpoint = () => { - void copy(endpoint) - } - - return ( -
-
-
-
- - - -

- {title} -

- {badge} -
- -
- - - {t(enabled ? 'agentDetail.access.status.inService' : 'agentDetail.access.status.outOfService')} - - -
-
- -
-
- {endpointLabel} -
-
- - {endpoint} - - - {badge !== undefined && badge !== null && ( - <> - - - - - )} -
-
-
- -
- {children} -
-
- ) -} - -function SsoBadge() { - const { t } = useTranslation('agentV2') - - return ( - - - {t('agentDetail.access.webApp.ssoEnabled')} - - ) -}