feat: access point settings

This commit is contained in:
yyh 2026-06-23 18:57:55 +08:00
parent 73c9017e63
commit 1e17c0f314
No known key found for this signature in database
7 changed files with 1041 additions and 230 deletions

View File

@ -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(
<AccessSurfaceCard
title="Web app"
icon="i-ri-window-line"
iconClassName="bg-state-accent-solid"
endpointLabel="Access URL"
endpoint="https://chat.example.test/agent/token"
enabled
onEnabledChange={vi.fn()}
copyLabel="Copy access URL"
>
<button type="button">Action</button>
</AccessSurfaceCard>,
)
await user.click(screen.getByRole('button', { name: 'Copy access URL' }))
expect(mockCopy).toHaveBeenCalledWith('https://chat.example.test/agent/token')
mockCopied = true
rerender(
<AccessSurfaceCard
title="Web app"
icon="i-ri-window-line"
iconClassName="bg-state-accent-solid"
endpointLabel="Access URL"
endpoint="https://chat.example.test/agent/token"
enabled
onEnabledChange={vi.fn()}
copyLabel="Copy access URL"
>
<button type="button">Action</button>
</AccessSurfaceCard>,
)
expect(screen.getByRole('button', { name: 'common.operation.copied' })).toBeInTheDocument()
})
})
})

View File

@ -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> = {}): 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<AgentAppDetailWithSite['site']> & {
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(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
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(
<WebAppAccessCard agent={createAgent()} agentId="agent-1" isLoading={false} />,
)
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(<ServiceApiAccessCard agentId="agent-1" />)
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(<ServiceApiAccessCard agentId="agent-1" />)
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',
},
})
})
})
})
})

View File

@ -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 (
<article className="rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs">
<div className="px-4 pt-4 pb-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className={cn('flex size-6 shrink-0 items-center justify-center rounded-lg', iconClassName)}>
<span aria-hidden className={cn(icon, 'size-4')} />
</span>
<h3 className="truncate system-md-semibold text-text-secondary">
{title}
</h3>
{badge}
</div>
<div className="flex shrink-0 items-center gap-3">
<span className={cn(
'inline-flex items-center gap-1 system-xs-semibold-uppercase',
enabled ? 'text-util-colors-green-green-700' : 'text-text-tertiary',
)}
>
<StatusDot status={enabled ? 'success' : 'disabled'} size="small" />
{t(enabled ? 'agentDetail.access.status.inService' : 'agentDetail.access.status.outOfService')}
</span>
<Switch
size="md"
checked={enabled}
disabled={switchDisabled}
aria-label={t('agentDetail.access.toggleSurface', { name: title })}
onCheckedChange={onEnabledChange}
/>
</div>
</div>
<div className="mt-3">
<div className="system-xs-medium text-text-tertiary">
{endpointLabel}
</div>
<div className="mt-1 flex h-8 min-w-0 items-center rounded-lg bg-components-input-bg-normal px-2">
<span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary" translate="no">
{endpoint || t('agentDetail.access.workflow.notAvailable')}
</span>
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={copied ? tCommon('operation.copied') : copyLabel}
disabled={!canCopyEndpoint}
onClick={handleCopyEndpoint}
>
<span aria-hidden className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'size-4')} />
</Button>
{endpointActions}
</div>
</div>
</div>
<div className="flex min-h-16 flex-wrap items-center gap-2 border-t border-divider-subtle px-4 py-4">
{children}
</div>
</article>
)
}

View File

@ -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<ApiKeyItem | null>(null)
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeyItem | null>(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 (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex w-full max-w-[800px]! flex-col overflow-hidden">
<DialogCloseButton />
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('apiKeyModal.apiSecretKey')}
</DialogTitle>
<DialogDescription className="mt-2 system-sm-regular text-text-tertiary">
{t('apiKeyModal.apiSecretKeyTips')}
</DialogDescription>
{newKey && (
<div className="mt-4 rounded-lg border border-components-panel-border bg-background-section-burn p-3">
<div className="system-xs-medium text-text-tertiary">
{t('apiKeyModal.generateTips')}
</div>
<div className="mt-2 flex h-8 min-w-0 items-center rounded-lg bg-components-input-bg-normal px-2">
<span className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary" translate="no">
{newKey.token}
</span>
<CopyFeedback content={newKey.token} />
</div>
</div>
)}
<div className="mt-4 min-h-40 overflow-hidden rounded-lg border border-divider-subtle">
<div className="flex h-9 shrink-0 items-center border-b border-divider-subtle bg-background-section-burn system-xs-semibold-uppercase text-text-tertiary">
<div className="w-64 shrink-0 px-3">{t('apiKeyModal.secretKey')}</div>
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.created')}</div>
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.lastUsed')}</div>
<div className="grow px-3" />
</div>
<div className="max-h-[280px] overflow-auto">
{apiKeysQuery.isPending && (
<div role="status" className="flex h-20 items-center justify-center system-sm-regular text-text-tertiary">
{t('loading')}
</div>
)}
{apiKeysQuery.isError && (
<div className="flex h-20 items-center justify-center gap-2 system-sm-regular text-text-tertiary">
<span>{tCommon('api.actionFailed')}</span>
<Button
variant="secondary"
size="small"
onClick={() => {
void apiKeysQuery.refetch()
}}
>
{tCommon('operation.retry')}
</Button>
</div>
)}
{apiKeysQuery.isSuccess && apiKeys.length === 0 && (
<div className="flex h-20 items-center justify-center system-sm-regular text-text-tertiary">
{tCommon('noData')}
</div>
)}
{apiKeysQuery.isSuccess && apiKeys.map(apiKey => (
<div className="flex h-9 items-center border-b border-divider-subtle system-sm-regular text-text-secondary last:border-b-0" key={apiKey.id}>
<div className="w-64 shrink-0 truncate px-3 font-mono" translate="no">
{maskApiKey(apiKey.token)}
</div>
<div className="w-[200px] shrink-0 truncate px-3">
{apiKey.created_at ? formatTime(apiKey.created_at, t('dateTimeFormat', { ns: 'appLog' })) : t('never')}
</div>
<div className="w-[200px] shrink-0 truncate px-3">
{apiKey.last_used_at ? formatTime(apiKey.last_used_at, t('dateTimeFormat', { ns: 'appLog' })) : t('never')}
</div>
<div className="flex grow justify-end gap-1 px-3">
<CopyFeedback content={apiKey.token} />
<Button
variant="ghost"
size="small"
className="size-6 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={tCommon('operation.delete')}
disabled={isDeleting}
onClick={() => setApiKeyToDelete(apiKey)}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<div className="mt-4 flex justify-start">
<Button onClick={handleCreateApiKey} loading={isCreating}>
<span aria-hidden className="mr-1 i-heroicons-plus-20-solid size-4" />
{t('apiKeyModal.createNewSecretKey')}
</Button>
</div>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(apiKeyToDelete)}
onOpenChange={(nextOpen) => {
if (!nextOpen)
setApiKeyToDelete(null)
}}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('actionMsg.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('actionMsg.deleteConfirmTips')}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{tCommon('operation.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} onClick={handleDeleteApiKey}>
{tCommon('operation.confirm')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function maskApiKey(token: string) {
if (token.length <= 24)
return token
return `${token.slice(0, 3)}...${token.slice(-20)}`
}

View File

@ -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<AgentAppDetailWithSite | undefined>(
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 (
<>
<AccessSurfaceCard
title={t('agentDetail.access.serviceApi.title')}
icon="i-ri-node-tree"
iconClassName="bg-state-accent-solid text-text-primary-on-surface"
endpointLabel={t('agentDetail.access.serviceApi.endpoint')}
endpoint={apiAccess?.service_api_base_url ?? ''}
enabled={Boolean(apiAccess?.enabled)}
onEnabledChange={handleEnabledChange}
copyLabel={t('agentDetail.access.copyServiceEndpoint')}
disabled={apiAccessQuery.isPending || apiAccessQuery.isError}
busy={toggleServiceApiMutation.isPending}
>
<Button
variant="secondary"
size="medium"
className="gap-1.5 px-3"
disabled={isBusy || apiAccessQuery.isError}
onClick={() => setApiKeyModalOpen(true)}
>
<span aria-hidden className="i-ri-key-2-line size-4" />
{t('agentDetail.access.serviceApi.actions.apiKey')}
<span className="rounded-md bg-components-badge-bg-gray-soft px-1.5 code-xs-regular text-text-tertiary">
{apiAccess?.api_key_count ?? 0}
</span>
</Button>
<a
href={docLink('/use-dify/publish/developing-with-apis')}
target="_blank"
rel="noreferrer"
aria-label={t('agentDetail.access.serviceApi.actions.apiReference')}
className={accessSurfaceActionClassName}
>
<span aria-hidden className="i-ri-book-open-line size-4" />
{t('agentDetail.access.serviceApi.actions.apiReference')}
</a>
{apiAccessQuery.isError && (
<Button
variant="secondary"
size="medium"
className="gap-1.5 px-3"
onClick={() => {
void apiAccessQuery.refetch()
}}
>
<span aria-hidden className="i-ri-refresh-line size-4" />
{tCommon('operation.retry')}
</Button>
)}
</AccessSurfaceCard>
<AgentApiKeyModal
agentId={agentId}
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
/>
</>
)
}

View File

@ -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<AgentAppDetailWithSite['site']> & {
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<AgentAppDetailWithSite | undefined>(
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<AgentAppDetailWithSite | undefined>(
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 (
<AccessSurfaceCard
title={t('agentDetail.access.webApp.title')}
icon="i-ri-window-line"
iconClassName="bg-state-accent-solid text-text-primary-on-surface"
endpointLabel={t('agentDetail.access.webApp.accessUrl')}
endpoint={webAppUrl}
enabled={isEnabled}
onEnabledChange={handleEnabledChange}
copyLabel={t('agentDetail.access.copyAccessUrl')}
badge={showSsoBadge ? <SsoBadge /> : undefined}
endpointActions={webAppUrl
? (
<>
<span className="mx-1.5 h-3.5 w-px shrink-0 bg-divider-regular" />
<ShareQRCode content={webAppUrl} />
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={t('agentDetail.access.webApp.refreshUrl')}
disabled={!canManageWebApp || isBusy}
onClick={handleRefreshUrl}
>
<span aria-hidden className="i-ri-refresh-line size-4" />
</Button>
</>
)
: undefined}
disabled={isLoading || !canManageWebApp}
busy={isBusy}
>
{webAppUrl && isEnabled
? (
<a
href={webAppUrl}
target="_blank"
rel="noreferrer"
aria-label={t('agentDetail.access.webApp.actions.launch')}
className={accessSurfaceActionClassName}
>
<span aria-hidden className="i-ri-external-link-line size-4" />
{t('agentDetail.access.webApp.actions.launch')}
</a>
)
: (
<Button variant="secondary" size="medium" className="gap-1.5 px-3" disabled>
<span aria-hidden className="i-ri-external-link-line size-4" />
{t('agentDetail.access.webApp.actions.launch')}
</Button>
)}
<Button variant="secondary" size="medium" className="gap-1.5 px-3" disabled>
<span aria-hidden className="i-ri-window-line size-4" />
{t('agentDetail.access.webApp.actions.embedded')}
</Button>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" disabled>
<span aria-hidden className="i-ri-paint-brush-line size-4" />
{t('agentDetail.access.webApp.actions.customize')}
</Button>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" disabled>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
{t('agentDetail.access.webApp.actions.settings')}
</Button>
</AccessSurfaceCard>
)
}
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 (
<span className="inline-flex h-4.5 shrink-0 items-center gap-1 rounded-sm border border-divider-deep px-1.5 system-2xs-semibold-uppercase text-text-tertiary">
<span aria-hidden className="i-ri-shield-check-line size-3" />
{t('agentDetail.access.webApp.ssoEnabled')}
</span>
)
}

View File

@ -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<AgentAppDetailWithSite['site']> & {
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 (
<section
@ -104,71 +59,8 @@ export function AgentAccessPage({
>
<div className="w-full min-w-0 space-y-6">
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
<AccessSurfaceCard
title={t('agentDetail.access.webApp.title')}
icon="i-ri-window-line"
iconClassName="bg-state-accent-solid text-text-primary-on-surface"
endpointLabel={t('agentDetail.access.webApp.accessUrl')}
endpoint={webAppUrl}
enabled={isWebAppEnabled}
onEnabledChange={setIsWebAppEnabled}
copyLabel={t('agentDetail.access.copyAccessUrl')}
badge={<SsoBadge />}
onUnavailableAction={handleUnavailableAction}
>
<Button
variant="secondary"
size="medium"
nativeButton={false}
className="gap-1.5 px-3"
render={<a href={webAppUrl} target="_blank" rel="noreferrer" aria-label={t('agentDetail.access.webApp.actions.launch')} />}
>
<span aria-hidden className="i-ri-external-link-line size-4" />
{t('agentDetail.access.webApp.actions.launch')}
</Button>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" onClick={handleUnavailableAction}>
<span aria-hidden className="i-ri-window-line size-4" />
{t('agentDetail.access.webApp.actions.embedded')}
</Button>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" onClick={handleUnavailableAction}>
<span aria-hidden className="i-ri-paint-brush-line size-4" />
{t('agentDetail.access.webApp.actions.customize')}
</Button>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" onClick={handleUnavailableAction}>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
{t('agentDetail.access.webApp.actions.settings')}
</Button>
</AccessSurfaceCard>
<AccessSurfaceCard
title={t('agentDetail.access.serviceApi.title')}
icon="i-ri-node-tree"
iconClassName="bg-state-accent-solid text-text-primary-on-surface"
endpointLabel={t('agentDetail.access.serviceApi.endpoint')}
endpoint={serviceApiEndpoint}
enabled={isServiceApiEnabled}
onEnabledChange={setIsServiceApiEnabled}
copyLabel={t('agentDetail.access.copyServiceEndpoint')}
onUnavailableAction={handleUnavailableAction}
>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" onClick={handleUnavailableAction}>
<span aria-hidden className="i-ri-key-2-line size-4" />
{t('agentDetail.access.serviceApi.actions.apiKey')}
<span className="rounded-md bg-components-badge-bg-gray-soft px-1.5 code-xs-regular text-text-tertiary">
21
</span>
</Button>
<Button
variant="secondary"
size="medium"
nativeButton={false}
className="gap-1.5 px-3"
render={<a href={docLink('/use-dify/publish/developing-with-apis')} target="_blank" rel="noreferrer" aria-label={t('agentDetail.access.serviceApi.actions.apiReference')} />}
>
<span aria-hidden className="i-ri-book-open-line size-4" />
{t('agentDetail.access.serviceApi.actions.apiReference')}
</Button>
</AccessSurfaceCard>
<WebAppAccessCard agent={agentQuery.data} agentId={agentId} isLoading={agentQuery.isPending} />
<ServiceApiAccessCard agentId={agentId} />
</div>
<section aria-labelledby="agent-workflow-access-title">
@ -188,121 +80,3 @@ export function AgentAccessPage({
</section>
)
}
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 (
<article className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs">
<div className="px-4 pt-4 pb-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className={cn('flex size-6 shrink-0 items-center justify-center rounded-lg', iconClassName)}>
<span aria-hidden className={cn(icon, 'size-4')} />
</span>
<h3 className="truncate system-md-semibold text-text-secondary">
{title}
</h3>
{badge}
</div>
<div className="flex shrink-0 items-center gap-3">
<span className={cn(
'inline-flex items-center gap-1 system-xs-semibold-uppercase',
enabled ? 'text-util-colors-green-green-700' : 'text-text-tertiary',
)}
>
<StatusDot status={enabled ? 'success' : 'disabled'} size="small" />
{t(enabled ? 'agentDetail.access.status.inService' : 'agentDetail.access.status.outOfService')}
</span>
<Switch
size="md"
checked={enabled}
aria-label={t('agentDetail.access.toggleSurface', { name: title })}
onCheckedChange={onEnabledChange}
/>
</div>
</div>
<div className="mt-3">
<div className="system-xs-medium text-text-tertiary">
{endpointLabel}
</div>
<div className="mt-1 flex h-8 min-w-0 items-center rounded-lg bg-components-input-bg-normal px-2">
<span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary" translate="no">
{endpoint}
</span>
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={copyLabel}
onClick={handleCopyEndpoint}
>
<span aria-hidden className="i-ri-file-copy-line size-4" />
</Button>
{badge !== undefined && badge !== null && (
<>
<span className="mx-1.5 h-3.5 w-px shrink-0 bg-divider-regular" />
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={t('agentDetail.access.webApp.showQrCode')}
onClick={onUnavailableAction}
>
<span aria-hidden className="i-ri-qr-code-line size-4" />
</Button>
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={t('agentDetail.access.webApp.refreshUrl')}
onClick={onUnavailableAction}
>
<span aria-hidden className="i-ri-refresh-line size-4" />
</Button>
</>
)}
</div>
</div>
</div>
<div className="flex min-h-16 flex-wrap items-center gap-2 border-t border-divider-subtle px-4 py-4">
{children}
</div>
</article>
)
}
function SsoBadge() {
const { t } = useTranslation('agentV2')
return (
<span className="inline-flex h-4.5 shrink-0 items-center gap-1 rounded-sm border border-divider-deep px-1.5 system-2xs-semibold-uppercase text-text-tertiary">
<span aria-hidden className="i-ri-shield-check-line size-3" />
{t('agentDetail.access.webApp.ssoEnabled')}
</span>
)
}