mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 23:01:11 +08:00
feat: access point settings
This commit is contained in:
parent
73c9017e63
commit
1e17c0f314
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)}`
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user