mirror of
https://github.com/langgenius/dify.git
synced 2026-08-28 21:23:22 +08:00
feat: implement trigger plugin authentication UI (#25310)
This commit is contained in:
parent
b6c552df07
commit
98ba0236e6
@ -1,487 +0,0 @@
|
|||||||
/**
|
|
||||||
* useDynamicTestRunOptions Hook Test
|
|
||||||
*
|
|
||||||
* Tests for the dynamic test run options generation hook that replaces mock data
|
|
||||||
* with real workflow node data for the test run dropdown.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { renderHook } from '@testing-library/react'
|
|
||||||
import React from 'react'
|
|
||||||
import type { Node } from 'reactflow'
|
|
||||||
import { useDynamicTestRunOptions } from '@/app/components/workflow/hooks/use-dynamic-test-run-options'
|
|
||||||
import { BlockEnum } from '@/app/components/workflow/types'
|
|
||||||
import { CollectionType } from '@/app/components/tools/types'
|
|
||||||
|
|
||||||
// Mock react-i18next
|
|
||||||
jest.mock('react-i18next', () => ({
|
|
||||||
useTranslation: () => ({
|
|
||||||
t: (key: string) => {
|
|
||||||
const translations: Record<string, string> = {
|
|
||||||
'workflow.blocks.start': 'User Input',
|
|
||||||
'workflow.blocks.trigger-schedule': 'Schedule Trigger',
|
|
||||||
'workflow.blocks.trigger-webhook': 'Webhook Trigger',
|
|
||||||
'workflow.blocks.trigger-plugin': 'Plugin Trigger',
|
|
||||||
'workflow.common.runAllTriggers': 'Run all triggers',
|
|
||||||
}
|
|
||||||
return translations[key] || key
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock reactflow
|
|
||||||
const mockNodes: Node[] = []
|
|
||||||
jest.mock('reactflow', () => ({
|
|
||||||
useNodes: () => mockNodes,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock workflow store
|
|
||||||
const mockStore = {
|
|
||||||
buildInTools: [
|
|
||||||
{
|
|
||||||
id: 'builtin-tool-1',
|
|
||||||
name: 'Built-in Tool',
|
|
||||||
icon: 'builtin-icon.png',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
customTools: [
|
|
||||||
{
|
|
||||||
id: 'custom-tool-1',
|
|
||||||
name: 'Custom Tool',
|
|
||||||
icon: { content: 'custom-icon', background: '#fff' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
workflowTools: [
|
|
||||||
{
|
|
||||||
id: 'workflow-tool-1',
|
|
||||||
name: 'Workflow Tool',
|
|
||||||
icon: 'workflow-icon.png',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
mcpTools: [
|
|
||||||
{
|
|
||||||
id: 'mcp-tool-1',
|
|
||||||
name: 'MCP Tool',
|
|
||||||
icon: 'mcp-icon.png',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock('@/app/components/workflow/store', () => ({
|
|
||||||
useStore: (selector: any) => selector(mockStore),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock utils
|
|
||||||
jest.mock('@/utils', () => ({
|
|
||||||
canFindTool: (toolId: string, providerId: string) => toolId === providerId,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock useGetIcon
|
|
||||||
jest.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: () => ({
|
|
||||||
getIconUrl: (icon: string) => `https://example.com/icons/${icon}`,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock workflow entry utils
|
|
||||||
jest.mock('@/app/components/workflow/utils/workflow-entry', () => ({
|
|
||||||
getWorkflowEntryNode: (nodes: Node[]) => {
|
|
||||||
return nodes.find(node => node.data.type === BlockEnum.Start) || null
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock icon components
|
|
||||||
jest.mock('@/app/components/base/icons/src/vender/workflow/Home', () => {
|
|
||||||
return function MockHome({ className }: { className: string }) {
|
|
||||||
return <div data-testid="home-icon" className={className}>Home</div>
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
jest.mock('@/app/components/base/icons/src/vender/workflow', () => ({
|
|
||||||
Schedule: function MockSchedule({ className }: { className: string }) {
|
|
||||||
return <div data-testid="schedule-icon" className={className}>Schedule</div>
|
|
||||||
},
|
|
||||||
WebhookLine: function MockWebhookLine({ className }: { className: string }) {
|
|
||||||
return <div data-testid="webhook-icon" className={className}>Webhook</div>
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
jest.mock('@/app/components/base/app-icon', () => {
|
|
||||||
return function MockAppIcon({ icon, background, className }: any) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid="app-icon"
|
|
||||||
className={className}
|
|
||||||
data-icon={icon}
|
|
||||||
data-background={background}
|
|
||||||
>
|
|
||||||
AppIcon
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useDynamicTestRunOptions', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Clear mock nodes before each test
|
|
||||||
mockNodes.length = 0
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Empty workflow', () => {
|
|
||||||
it('should return empty options when no nodes exist', () => {
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.userInput).toBeUndefined()
|
|
||||||
expect(result.current.triggers).toEqual([])
|
|
||||||
expect(result.current.runAll).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Start node handling', () => {
|
|
||||||
it('should create user input option from Start node', () => {
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'start-1',
|
|
||||||
type: 'start',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {
|
|
||||||
type: BlockEnum.Start,
|
|
||||||
title: 'Custom Start',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.userInput).toEqual({
|
|
||||||
id: 'start-1',
|
|
||||||
type: 'user_input',
|
|
||||||
name: 'Custom Start',
|
|
||||||
icon: expect.any(Object),
|
|
||||||
nodeId: 'start-1',
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should use fallback translation when Start node has no title', () => {
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'start-1',
|
|
||||||
type: 'start',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {
|
|
||||||
type: BlockEnum.Start,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.userInput?.name).toBe('User Input')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Trigger nodes handling', () => {
|
|
||||||
it('should create schedule trigger option', () => {
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'schedule-1',
|
|
||||||
type: 'trigger-schedule',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {
|
|
||||||
type: BlockEnum.TriggerSchedule,
|
|
||||||
title: 'Daily Schedule',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.triggers).toHaveLength(1)
|
|
||||||
expect(result.current.triggers[0]).toEqual({
|
|
||||||
id: 'schedule-1',
|
|
||||||
type: 'schedule',
|
|
||||||
name: 'Daily Schedule',
|
|
||||||
icon: expect.any(Object),
|
|
||||||
nodeId: 'schedule-1',
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should create webhook trigger option', () => {
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'webhook-1',
|
|
||||||
type: 'trigger-webhook',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {
|
|
||||||
type: BlockEnum.TriggerWebhook,
|
|
||||||
title: 'API Webhook',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.triggers).toHaveLength(1)
|
|
||||||
expect(result.current.triggers[0]).toEqual({
|
|
||||||
id: 'webhook-1',
|
|
||||||
type: 'webhook',
|
|
||||||
name: 'API Webhook',
|
|
||||||
icon: expect.any(Object),
|
|
||||||
nodeId: 'webhook-1',
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should create plugin trigger option with built-in tool', () => {
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'plugin-1',
|
|
||||||
type: 'trigger-plugin',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {
|
|
||||||
type: BlockEnum.TriggerPlugin,
|
|
||||||
title: 'Plugin Trigger',
|
|
||||||
provider_id: 'builtin-tool-1',
|
|
||||||
provider_type: CollectionType.builtIn,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.triggers).toHaveLength(1)
|
|
||||||
expect(result.current.triggers[0]).toEqual({
|
|
||||||
id: 'plugin-1',
|
|
||||||
type: 'plugin',
|
|
||||||
name: 'Plugin Trigger',
|
|
||||||
icon: expect.any(Object),
|
|
||||||
nodeId: 'plugin-1',
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should create plugin trigger option with custom tool', () => {
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'plugin-2',
|
|
||||||
type: 'trigger-plugin',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {
|
|
||||||
type: BlockEnum.TriggerPlugin,
|
|
||||||
title: 'Custom Plugin',
|
|
||||||
provider_id: 'custom-tool-1',
|
|
||||||
provider_type: CollectionType.custom,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.triggers).toHaveLength(1)
|
|
||||||
expect(result.current.triggers[0].icon).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should create plugin trigger option with fallback icon when tool not found', () => {
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'plugin-3',
|
|
||||||
type: 'trigger-plugin',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {
|
|
||||||
type: BlockEnum.TriggerPlugin,
|
|
||||||
title: 'Unknown Plugin',
|
|
||||||
provider_id: 'unknown-tool',
|
|
||||||
provider_type: CollectionType.builtIn,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.triggers).toHaveLength(1)
|
|
||||||
expect(result.current.triggers[0].name).toBe('Unknown Plugin')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Run all triggers option', () => {
|
|
||||||
it('should create runAll option when multiple triggers exist', () => {
|
|
||||||
mockNodes.push(
|
|
||||||
{
|
|
||||||
id: 'schedule-1',
|
|
||||||
type: 'trigger-schedule',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: { type: BlockEnum.TriggerSchedule, title: 'Schedule 1' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'webhook-1',
|
|
||||||
type: 'trigger-webhook',
|
|
||||||
position: { x: 100, y: 0 },
|
|
||||||
data: { type: BlockEnum.TriggerWebhook, title: 'Webhook 1' },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.runAll).toEqual({
|
|
||||||
id: 'run-all',
|
|
||||||
type: 'all',
|
|
||||||
name: 'Run all triggers',
|
|
||||||
icon: expect.any(Object),
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not create runAll option when only one trigger exists', () => {
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'schedule-1',
|
|
||||||
type: 'trigger-schedule',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: { type: BlockEnum.TriggerSchedule, title: 'Schedule 1' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.runAll).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not create runAll option when no triggers exist', () => {
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'start-1',
|
|
||||||
type: 'start',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: { type: BlockEnum.Start, title: 'Start' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.runAll).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Complex workflow scenarios', () => {
|
|
||||||
it('should handle workflow with all node types', () => {
|
|
||||||
mockNodes.push(
|
|
||||||
{
|
|
||||||
id: 'start-1',
|
|
||||||
type: 'start',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: { type: BlockEnum.Start, title: 'User Input' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'schedule-1',
|
|
||||||
type: 'trigger-schedule',
|
|
||||||
position: { x: 100, y: 0 },
|
|
||||||
data: { type: BlockEnum.TriggerSchedule, title: 'Schedule Trigger' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'webhook-1',
|
|
||||||
type: 'trigger-webhook',
|
|
||||||
position: { x: 200, y: 0 },
|
|
||||||
data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'plugin-1',
|
|
||||||
type: 'trigger-plugin',
|
|
||||||
position: { x: 300, y: 0 },
|
|
||||||
data: {
|
|
||||||
type: BlockEnum.TriggerPlugin,
|
|
||||||
title: 'Plugin Trigger',
|
|
||||||
provider_id: 'builtin-tool-1',
|
|
||||||
provider_type: CollectionType.builtIn,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.userInput).toBeDefined()
|
|
||||||
expect(result.current.triggers).toHaveLength(3)
|
|
||||||
expect(result.current.runAll).toBeDefined()
|
|
||||||
|
|
||||||
// Verify node ID mapping for future click functionality
|
|
||||||
expect(result.current.userInput?.nodeId).toBe('start-1')
|
|
||||||
expect(result.current.triggers[0].nodeId).toBe('schedule-1')
|
|
||||||
expect(result.current.triggers[1].nodeId).toBe('webhook-1')
|
|
||||||
expect(result.current.triggers[2].nodeId).toBe('plugin-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle nodes with missing data gracefully', () => {
|
|
||||||
mockNodes.push(
|
|
||||||
{
|
|
||||||
id: 'invalid-1',
|
|
||||||
type: 'unknown',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {}, // Missing type
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'start-1',
|
|
||||||
type: 'start',
|
|
||||||
position: { x: 100, y: 0 },
|
|
||||||
data: { type: BlockEnum.Start },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
// Should only process valid nodes
|
|
||||||
expect(result.current.userInput).toBeDefined()
|
|
||||||
expect(result.current.triggers).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should use workflow entry node as fallback when no direct Start node exists', () => {
|
|
||||||
// No Start node, but workflow entry utility finds a fallback
|
|
||||||
mockNodes.push({
|
|
||||||
id: 'fallback-1',
|
|
||||||
type: 'other',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: { type: BlockEnum.Start, title: 'Fallback Start' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
expect(result.current.userInput).toBeDefined()
|
|
||||||
expect(result.current.userInput?.nodeId).toBe('fallback-1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Node ID mapping for click functionality', () => {
|
|
||||||
it('should ensure all options have nodeId for proper click handling', () => {
|
|
||||||
mockNodes.push(
|
|
||||||
{
|
|
||||||
id: 'start-node-123',
|
|
||||||
type: 'start',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: { type: BlockEnum.Start, title: 'Start' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'trigger-node-456',
|
|
||||||
type: 'trigger-schedule',
|
|
||||||
position: { x: 100, y: 0 },
|
|
||||||
data: { type: BlockEnum.TriggerSchedule, title: 'Schedule' },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
// Verify node ID mapping exists for click functionality
|
|
||||||
expect(result.current.userInput?.nodeId).toBe('start-node-123')
|
|
||||||
expect(result.current.triggers[0].nodeId).toBe('trigger-node-456')
|
|
||||||
|
|
||||||
// runAll doesn't need nodeId as it handles multiple nodes
|
|
||||||
expect(result.current.runAll?.nodeId).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Icon rendering verification', () => {
|
|
||||||
it('should render proper icon components for each node type', () => {
|
|
||||||
mockNodes.push(
|
|
||||||
{
|
|
||||||
id: 'start-1',
|
|
||||||
type: 'start',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: { type: BlockEnum.Start },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'schedule-1',
|
|
||||||
type: 'trigger-schedule',
|
|
||||||
position: { x: 100, y: 0 },
|
|
||||||
data: { type: BlockEnum.TriggerSchedule },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDynamicTestRunOptions())
|
|
||||||
|
|
||||||
// Icons should be React elements
|
|
||||||
expect(React.isValidElement(result.current.userInput?.icon)).toBe(true)
|
|
||||||
expect(React.isValidElement(result.current.triggers[0]?.icon)).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -49,6 +49,8 @@ import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workfl
|
|||||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||||
import { useStore } from '@/app/components/workflow/store'
|
import { useStore } from '@/app/components/workflow/store'
|
||||||
import Tab, { TabType } from './tab'
|
import Tab, { TabType } from './tab'
|
||||||
|
import { useAllTriggerPlugins, useTriggerSubscriptions } from '@/service/use-triggers'
|
||||||
|
import AuthMethodSelector from '@/app/components/workflow/nodes/trigger-plugin/components/auth-method-selector'
|
||||||
import LastRun from './last-run'
|
import LastRun from './last-run'
|
||||||
import useLastRun from './last-run/use-last-run'
|
import useLastRun from './last-run/use-last-run'
|
||||||
import BeforeRunForm from '../before-run-form'
|
import BeforeRunForm from '../before-run-form'
|
||||||
@ -236,11 +238,59 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||||||
return buildInTools.find(item => canFindTool(item.id, data.provider_id))
|
return buildInTools.find(item => canFindTool(item.id, data.provider_id))
|
||||||
}, [buildInTools, data.provider_id])
|
}, [buildInTools, data.provider_id])
|
||||||
|
|
||||||
|
// For trigger plugins, check if they have existing subscriptions (authenticated)
|
||||||
|
const triggerProvider = useMemo(() => {
|
||||||
|
if (data.type === BlockEnum.TriggerPlugin) {
|
||||||
|
if (data.provider_id && data.provider_name)
|
||||||
|
return `${data.provider_id}/${data.provider_name}`
|
||||||
|
return data.provider_id || ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}, [data.type, data.provider_id, data.provider_name])
|
||||||
|
|
||||||
|
const { data: triggerSubscriptions = [] } = useTriggerSubscriptions(
|
||||||
|
triggerProvider,
|
||||||
|
data.type === BlockEnum.TriggerPlugin && !!triggerProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: triggerProviders = [] } = useAllTriggerPlugins()
|
||||||
|
|
||||||
|
const currentTriggerProvider = useMemo(() => {
|
||||||
|
if (data.type !== BlockEnum.TriggerPlugin || !data.provider_id || !data.provider_name)
|
||||||
|
return undefined
|
||||||
|
return triggerProviders.find(p => p.plugin_id === data.provider_id && p.name === data.provider_name)
|
||||||
|
}, [data.type, data.provider_id, data.provider_name, triggerProviders])
|
||||||
|
|
||||||
|
const supportedAuthMethods = useMemo(() => {
|
||||||
|
if (!currentTriggerProvider) return []
|
||||||
|
const methods = []
|
||||||
|
if (currentTriggerProvider.oauth_client_schema && currentTriggerProvider.oauth_client_schema.length > 0)
|
||||||
|
methods.push('oauth')
|
||||||
|
if (currentTriggerProvider.credentials_schema && currentTriggerProvider.credentials_schema.length > 0)
|
||||||
|
methods.push('api_key')
|
||||||
|
return methods
|
||||||
|
}, [currentTriggerProvider])
|
||||||
|
|
||||||
|
const isTriggerAuthenticated = useMemo(() => {
|
||||||
|
if (data.type !== BlockEnum.TriggerPlugin) return true
|
||||||
|
if (!triggerSubscriptions.length) return false
|
||||||
|
|
||||||
|
const subscription = triggerSubscriptions[0]
|
||||||
|
return subscription.credential_type !== 'unauthorized'
|
||||||
|
}, [data.type, triggerSubscriptions])
|
||||||
|
|
||||||
|
const shouldShowAuthSelector = useMemo(() => {
|
||||||
|
return data.type === BlockEnum.TriggerPlugin
|
||||||
|
&& !isTriggerAuthenticated
|
||||||
|
&& supportedAuthMethods.length > 0
|
||||||
|
&& !!currentTriggerProvider
|
||||||
|
}, [data.type, isTriggerAuthenticated, supportedAuthMethods.length, currentTriggerProvider])
|
||||||
|
|
||||||
// Unified check for any node that needs authentication UI
|
// Unified check for any node that needs authentication UI
|
||||||
const needsAuth = useMemo(() => {
|
const needsAuth = useMemo(() => {
|
||||||
return (data.type === BlockEnum.Tool && currCollection?.allow_delete)
|
return (data.type === BlockEnum.Tool && currCollection?.allow_delete)
|
||||||
|| (data.type === BlockEnum.TriggerPlugin)
|
|| (data.type === BlockEnum.TriggerPlugin && isTriggerAuthenticated)
|
||||||
}, [data.type, currCollection?.allow_delete])
|
}, [data.type, currCollection?.allow_delete, isTriggerAuthenticated])
|
||||||
|
|
||||||
const handleAuthorizationItemClick = useCallback((credential_id: string) => {
|
const handleAuthorizationItemClick = useCallback((credential_id: string) => {
|
||||||
handleNodeDataUpdateWithSyncDraft({
|
handleNodeDataUpdateWithSyncDraft({
|
||||||
@ -419,7 +469,15 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!needsAuth && (
|
shouldShowAuthSelector && (
|
||||||
|
<AuthMethodSelector
|
||||||
|
provider={currentTriggerProvider!}
|
||||||
|
supportedMethods={supportedAuthMethods}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!needsAuth && data.type !== BlockEnum.TriggerPlugin && (
|
||||||
<div className='flex items-center justify-between pl-4 pr-3'>
|
<div className='flex items-center justify-between pl-4 pr-3'>
|
||||||
<Tab
|
<Tab
|
||||||
value={tabType}
|
value={tabType}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { memo, useCallback, useMemo } from 'react'
|
import { memo, useCallback, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { AuthorizedInNode } from '@/app/components/plugins/plugin-auth'
|
import { AuthorizedInNode } from '@/app/components/plugins/plugin-auth'
|
||||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
|
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
|
||||||
import { BlockEnum, type Node } from '@/app/components/workflow/types'
|
import { BlockEnum, type Node } from '@/app/components/workflow/types'
|
||||||
@ -14,6 +15,7 @@ import {
|
|||||||
useTriggerSubscriptions,
|
useTriggerSubscriptions,
|
||||||
} from '@/service/use-triggers'
|
} from '@/service/use-triggers'
|
||||||
import { useToastContext } from '@/app/components/base/toast'
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
|
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||||
|
|
||||||
type NodeAuthProps = {
|
type NodeAuthProps = {
|
||||||
data: Node['data']
|
data: Node['data']
|
||||||
@ -21,6 +23,7 @@ type NodeAuthProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange }) => {
|
const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const buildInTools = useStore(s => s.buildInTools)
|
const buildInTools = useStore(s => s.buildInTools)
|
||||||
const { notify } = useToastContext()
|
const { notify } = useToastContext()
|
||||||
|
|
||||||
@ -89,19 +92,24 @@ const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange }) => {
|
|||||||
if (!provider) return
|
if (!provider) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Directly initiate OAuth flow, backend will automatically create subscription builder
|
|
||||||
const response = await initiateTriggerOAuth.mutateAsync(provider)
|
const response = await initiateTriggerOAuth.mutateAsync(provider)
|
||||||
if (response.authorization_url) {
|
if (response.authorization_url) {
|
||||||
// Open OAuth authorization window
|
openOAuthPopup(response.authorization_url, (callbackData) => {
|
||||||
const authWindow = window.open(response.authorization_url, 'oauth_authorization', 'width=600,height=600')
|
invalidateSubscriptions(provider)
|
||||||
|
|
||||||
// Monitor window closure and refresh subscription list
|
if (callbackData?.success === false) {
|
||||||
const checkClosed = setInterval(() => {
|
notify({
|
||||||
if (authWindow?.closed) {
|
type: 'error',
|
||||||
clearInterval(checkClosed)
|
message: callbackData.errorDescription || callbackData.error || t('workflow.nodes.triggerPlugin.authenticationFailed'),
|
||||||
invalidateSubscriptions(provider)
|
})
|
||||||
}
|
}
|
||||||
}, 1000)
|
else if (callbackData?.subscriptionId) {
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('workflow.nodes.triggerPlugin.authenticationSuccess'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error: any) {
|
catch (error: any) {
|
||||||
|
|||||||
@ -0,0 +1,185 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
jest.mock('react-i18next')
|
||||||
|
jest.mock('@/service/use-triggers', () => ({
|
||||||
|
useCreateTriggerSubscriptionBuilder: () => ({ mutateAsync: jest.fn().mockResolvedValue({ subscription_builder: { id: 'test-id' } }) }),
|
||||||
|
useUpdateTriggerSubscriptionBuilder: () => ({ mutateAsync: jest.fn() }),
|
||||||
|
useVerifyTriggerSubscriptionBuilder: () => ({ mutateAsync: jest.fn() }),
|
||||||
|
useBuildTriggerSubscription: () => ({ mutateAsync: jest.fn() }),
|
||||||
|
useInvalidateTriggerSubscriptions: () => jest.fn(),
|
||||||
|
}))
|
||||||
|
jest.mock('@/app/components/base/toast', () => ({
|
||||||
|
useToastContext: () => ({ notify: jest.fn() }),
|
||||||
|
}))
|
||||||
|
jest.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||||
|
toolCredentialToFormSchemas: jest.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
name: 'api_key',
|
||||||
|
label: { en_US: 'API Key' },
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
addDefaultValue: jest.fn().mockReturnValue({ api_key: '' }),
|
||||||
|
}))
|
||||||
|
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||||
|
useLanguage: () => 'en_US',
|
||||||
|
}))
|
||||||
|
jest.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => {
|
||||||
|
return function MockForm({ value, onChange, formSchemas }: any) {
|
||||||
|
return (
|
||||||
|
<div data-testid="mock-form">
|
||||||
|
{formSchemas.map((schema: any, index: number) => (
|
||||||
|
<div key={index}>
|
||||||
|
<label htmlFor={schema.name}>{schema.label?.en_US || schema.name}</label>
|
||||||
|
<input
|
||||||
|
id={schema.name}
|
||||||
|
data-testid={`input-${schema.name}`}
|
||||||
|
value={value[schema.name] || ''}
|
||||||
|
onChange={e => onChange({ ...value, [schema.name]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import ApiKeyConfigModal from '../api-key-config-modal'
|
||||||
|
|
||||||
|
const mockUseTranslation = useTranslation as jest.MockedFunction<typeof useTranslation>
|
||||||
|
|
||||||
|
const mockTranslation = {
|
||||||
|
t: (key: string, params?: any) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'workflow.nodes.triggerPlugin.configureApiKey': 'Configure API Key',
|
||||||
|
'workflow.nodes.triggerPlugin.apiKeyDescription': 'Configure API key credentials for authentication',
|
||||||
|
'workflow.nodes.triggerPlugin.apiKeyConfigured': 'API key configured successfully',
|
||||||
|
'workflow.nodes.triggerPlugin.configurationFailed': 'Configuration failed',
|
||||||
|
'common.operation.cancel': 'Cancel',
|
||||||
|
'common.operation.save': 'Save',
|
||||||
|
'common.errorMsg.fieldRequired': `${params?.field} is required`,
|
||||||
|
}
|
||||||
|
return translations[key] || key
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
plugin_id: 'test-plugin',
|
||||||
|
name: 'test-provider',
|
||||||
|
author: 'test',
|
||||||
|
label: { en_US: 'Test Provider' },
|
||||||
|
description: { en_US: 'Test Description' },
|
||||||
|
icon: 'test-icon.svg',
|
||||||
|
icon_dark: null,
|
||||||
|
tags: ['test'],
|
||||||
|
plugin_unique_identifier: 'test:1.0.0',
|
||||||
|
credentials_schema: [
|
||||||
|
{
|
||||||
|
type: 'secret-input' as const,
|
||||||
|
name: 'api_key',
|
||||||
|
required: true,
|
||||||
|
label: { en_US: 'API Key' },
|
||||||
|
scope: null,
|
||||||
|
default: null,
|
||||||
|
options: null,
|
||||||
|
help: null,
|
||||||
|
url: null,
|
||||||
|
placeholder: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
oauth_client_schema: [],
|
||||||
|
subscription_schema: {
|
||||||
|
parameters_schema: [],
|
||||||
|
properties_schema: [],
|
||||||
|
},
|
||||||
|
triggers: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseTranslation.mockReturnValue(mockTranslation as any)
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ApiKeyConfigModal', () => {
|
||||||
|
const mockProps = {
|
||||||
|
provider: mockProvider,
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
onSuccess: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render modal with correct title and description', () => {
|
||||||
|
render(<ApiKeyConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Configure API Key')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Configure API key credentials for authentication')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render form when credential schema is loaded', async () => {
|
||||||
|
render(<ApiKeyConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mock-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render form fields with correct labels', async () => {
|
||||||
|
render(<ApiKeyConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('API Key')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render cancel and save buttons', async () => {
|
||||||
|
render(<ApiKeyConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Form Interaction', () => {
|
||||||
|
it('should update form values on input change', async () => {
|
||||||
|
render(<ApiKeyConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const apiKeyInput = screen.getByTestId('input-api_key')
|
||||||
|
fireEvent.change(apiKeyInput, { target: { value: 'test-api-key' } })
|
||||||
|
expect(apiKeyInput).toHaveValue('test-api-key')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onCancel when cancel button is clicked', async () => {
|
||||||
|
render(<ApiKeyConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Save Process', () => {
|
||||||
|
it('should proceed with save when required fields are filled', async () => {
|
||||||
|
render(<ApiKeyConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const apiKeyInput = screen.getByTestId('input-api_key')
|
||||||
|
fireEvent.change(apiKeyInput, { target: { value: 'valid-api-key' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockProps.onSuccess).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
jest.mock('react-i18next')
|
||||||
|
jest.mock('@/service/use-triggers', () => ({
|
||||||
|
useInitiateTriggerOAuth: () => ({ mutateAsync: jest.fn() }),
|
||||||
|
useInvalidateTriggerSubscriptions: () => jest.fn(),
|
||||||
|
useTriggerOAuthConfig: () => ({ data: null }),
|
||||||
|
}))
|
||||||
|
jest.mock('@/hooks/use-oauth', () => ({
|
||||||
|
openOAuthPopup: jest.fn(),
|
||||||
|
}))
|
||||||
|
jest.mock('@/app/components/base/toast', () => ({
|
||||||
|
useToastContext: () => ({ notify: jest.fn() }),
|
||||||
|
}))
|
||||||
|
jest.mock('../api-key-config-modal', () => {
|
||||||
|
return function MockApiKeyConfigModal({ onCancel }: any) {
|
||||||
|
return (
|
||||||
|
<div data-testid="api-key-modal">
|
||||||
|
<button onClick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest.mock('../oauth-client-config-modal', () => {
|
||||||
|
return function MockOAuthClientConfigModal({ onCancel }: any) {
|
||||||
|
return (
|
||||||
|
<div data-testid="oauth-client-modal">
|
||||||
|
<button onClick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import AuthMethodSelector from '../auth-method-selector'
|
||||||
|
|
||||||
|
const mockUseTranslation = useTranslation as jest.MockedFunction<typeof useTranslation>
|
||||||
|
|
||||||
|
const mockTranslation = {
|
||||||
|
t: (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'workflow.nodes.triggerPlugin.or': 'OR',
|
||||||
|
'workflow.nodes.triggerPlugin.useOAuth': 'Use OAuth',
|
||||||
|
'workflow.nodes.triggerPlugin.useApiKey': 'Use API Key',
|
||||||
|
}
|
||||||
|
return translations[key] || key
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
plugin_id: 'test-plugin',
|
||||||
|
name: 'test-provider',
|
||||||
|
author: 'test',
|
||||||
|
label: { en_US: 'Test Provider', zh_Hans: '测试提供者' },
|
||||||
|
description: { en_US: 'Test Description', zh_Hans: '测试描述' },
|
||||||
|
icon: 'test-icon.svg',
|
||||||
|
icon_dark: null,
|
||||||
|
tags: ['test'],
|
||||||
|
plugin_unique_identifier: 'test:1.0.0',
|
||||||
|
credentials_schema: [
|
||||||
|
{
|
||||||
|
type: 'secret-input' as const,
|
||||||
|
name: 'api_key',
|
||||||
|
required: true,
|
||||||
|
label: { en_US: 'API Key', zh_Hans: 'API密钥' },
|
||||||
|
scope: null,
|
||||||
|
default: null,
|
||||||
|
options: null,
|
||||||
|
help: null,
|
||||||
|
url: null,
|
||||||
|
placeholder: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
oauth_client_schema: [
|
||||||
|
{
|
||||||
|
type: 'secret-input' as const,
|
||||||
|
name: 'client_id',
|
||||||
|
required: true,
|
||||||
|
label: { en_US: 'Client ID', zh_Hans: '客户端ID' },
|
||||||
|
scope: null,
|
||||||
|
default: null,
|
||||||
|
options: null,
|
||||||
|
help: null,
|
||||||
|
url: null,
|
||||||
|
placeholder: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subscription_schema: {
|
||||||
|
parameters_schema: [],
|
||||||
|
properties_schema: [],
|
||||||
|
},
|
||||||
|
triggers: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseTranslation.mockReturnValue(mockTranslation as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AuthMethodSelector', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should not render when no supported methods are available', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AuthMethodSelector
|
||||||
|
provider={mockProvider}
|
||||||
|
supportedMethods={[]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render OAuth button when oauth is supported', () => {
|
||||||
|
render(
|
||||||
|
<AuthMethodSelector
|
||||||
|
provider={mockProvider}
|
||||||
|
supportedMethods={['oauth']}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Use OAuth' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render API Key button when api_key is supported', () => {
|
||||||
|
render(
|
||||||
|
<AuthMethodSelector
|
||||||
|
provider={mockProvider}
|
||||||
|
supportedMethods={['api_key']}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Use API Key' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render both buttons and OR divider when both methods are supported', () => {
|
||||||
|
render(
|
||||||
|
<AuthMethodSelector
|
||||||
|
provider={mockProvider}
|
||||||
|
supportedMethods={['oauth', 'api_key']}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Use OAuth' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Use API Key' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('OR')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Modal Interactions', () => {
|
||||||
|
it('should open API Key modal when API Key button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<AuthMethodSelector
|
||||||
|
provider={mockProvider}
|
||||||
|
supportedMethods={['api_key']}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Use API Key' }))
|
||||||
|
expect(screen.getByTestId('api-key-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should close API Key modal when cancel is clicked', () => {
|
||||||
|
render(
|
||||||
|
<AuthMethodSelector
|
||||||
|
provider={mockProvider}
|
||||||
|
supportedMethods={['api_key']}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Use API Key' }))
|
||||||
|
expect(screen.getByTestId('api-key-modal')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Cancel'))
|
||||||
|
expect(screen.queryByTestId('api-key-modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open OAuth client config modal when OAuth settings button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<AuthMethodSelector
|
||||||
|
provider={mockProvider}
|
||||||
|
supportedMethods={['oauth']}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const settingsButtons = screen.getAllByRole('button')
|
||||||
|
const settingsButton = settingsButtons.find(button =>
|
||||||
|
button.querySelector('svg') && !button.textContent?.includes('Use OAuth'),
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(settingsButton!)
|
||||||
|
expect(screen.getByTestId('oauth-client-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
jest.mock('react-i18next')
|
||||||
|
jest.mock('@/service/use-triggers', () => ({
|
||||||
|
useConfigureTriggerOAuth: () => ({ mutateAsync: jest.fn() }),
|
||||||
|
useInvalidateTriggerOAuthConfig: () => jest.fn(),
|
||||||
|
useTriggerOAuthConfig: () => ({ data: null, isLoading: false }),
|
||||||
|
}))
|
||||||
|
jest.mock('@/app/components/base/toast', () => ({
|
||||||
|
useToastContext: () => ({ notify: jest.fn() }),
|
||||||
|
}))
|
||||||
|
jest.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||||
|
toolCredentialToFormSchemas: jest.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
name: 'client_id',
|
||||||
|
label: { en_US: 'Client ID' },
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'client_secret',
|
||||||
|
label: { en_US: 'Client Secret' },
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
addDefaultValue: jest.fn().mockReturnValue({ client_id: '', client_secret: '' }),
|
||||||
|
}))
|
||||||
|
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||||
|
useLanguage: () => 'en_US',
|
||||||
|
}))
|
||||||
|
jest.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => {
|
||||||
|
return function MockForm({ value, onChange, formSchemas }: any) {
|
||||||
|
return (
|
||||||
|
<div data-testid="mock-form">
|
||||||
|
{formSchemas.map((schema: any, index: number) => (
|
||||||
|
<div key={index}>
|
||||||
|
<label htmlFor={schema.name}>{schema.label?.en_US || schema.name}</label>
|
||||||
|
<input
|
||||||
|
id={schema.name}
|
||||||
|
data-testid={`input-${schema.name}`}
|
||||||
|
value={value[schema.name] || ''}
|
||||||
|
onChange={e => onChange({ ...value, [schema.name]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import OAuthClientConfigModal from '../oauth-client-config-modal'
|
||||||
|
|
||||||
|
const mockUseTranslation = useTranslation as jest.MockedFunction<typeof useTranslation>
|
||||||
|
|
||||||
|
const mockTranslation = {
|
||||||
|
t: (key: string, params?: any) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'workflow.nodes.triggerPlugin.configureOAuthClient': 'Configure OAuth Client',
|
||||||
|
'workflow.nodes.triggerPlugin.oauthClientDescription': 'Configure OAuth client credentials to enable authentication',
|
||||||
|
'workflow.nodes.triggerPlugin.oauthClientSaved': 'OAuth client configuration saved successfully',
|
||||||
|
'workflow.nodes.triggerPlugin.configurationFailed': 'Configuration failed',
|
||||||
|
'common.operation.cancel': 'Cancel',
|
||||||
|
'common.operation.save': 'Save',
|
||||||
|
'common.errorMsg.fieldRequired': `${params?.field} is required`,
|
||||||
|
}
|
||||||
|
return translations[key] || key
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
plugin_id: 'test-plugin',
|
||||||
|
name: 'test-provider',
|
||||||
|
author: 'test',
|
||||||
|
label: { en_US: 'Test Provider' },
|
||||||
|
description: { en_US: 'Test Description' },
|
||||||
|
icon: 'test-icon.svg',
|
||||||
|
icon_dark: null,
|
||||||
|
tags: ['test'],
|
||||||
|
plugin_unique_identifier: 'test:1.0.0',
|
||||||
|
credentials_schema: [],
|
||||||
|
oauth_client_schema: [
|
||||||
|
{
|
||||||
|
type: 'secret-input' as const,
|
||||||
|
name: 'client_id',
|
||||||
|
required: true,
|
||||||
|
label: { en_US: 'Client ID' },
|
||||||
|
scope: null,
|
||||||
|
default: null,
|
||||||
|
options: null,
|
||||||
|
help: null,
|
||||||
|
url: null,
|
||||||
|
placeholder: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'secret-input' as const,
|
||||||
|
name: 'client_secret',
|
||||||
|
required: true,
|
||||||
|
label: { en_US: 'Client Secret' },
|
||||||
|
scope: null,
|
||||||
|
default: null,
|
||||||
|
options: null,
|
||||||
|
help: null,
|
||||||
|
url: null,
|
||||||
|
placeholder: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subscription_schema: {
|
||||||
|
parameters_schema: [],
|
||||||
|
properties_schema: [],
|
||||||
|
},
|
||||||
|
triggers: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseTranslation.mockReturnValue(mockTranslation as any)
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OAuthClientConfigModal', () => {
|
||||||
|
const mockProps = {
|
||||||
|
provider: mockProvider,
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
onSuccess: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render modal with correct title and description', () => {
|
||||||
|
render(<OAuthClientConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Configure OAuth Client')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Configure OAuth client credentials to enable authentication')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render form when schema is loaded', async () => {
|
||||||
|
render(<OAuthClientConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mock-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render form fields with correct labels', async () => {
|
||||||
|
render(<OAuthClientConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Client ID')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Client Secret')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render cancel and save buttons', async () => {
|
||||||
|
render(<OAuthClientConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Form Interaction', () => {
|
||||||
|
it('should update form values on input change', async () => {
|
||||||
|
render(<OAuthClientConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const clientIdInput = screen.getByTestId('input-client_id')
|
||||||
|
fireEvent.change(clientIdInput, { target: { value: 'test-client-id' } })
|
||||||
|
expect(clientIdInput).toHaveValue('test-client-id')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onCancel when cancel button is clicked', async () => {
|
||||||
|
render(<OAuthClientConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Save Process', () => {
|
||||||
|
it('should proceed with save when required fields are filled', async () => {
|
||||||
|
render(<OAuthClientConfigModal {...mockProps} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const clientIdInput = screen.getByTestId('input-client_id')
|
||||||
|
const clientSecretInput = screen.getByTestId('input-client_secret')
|
||||||
|
|
||||||
|
fireEvent.change(clientIdInput, { target: { value: 'valid-client-id' } })
|
||||||
|
fireEvent.change(clientSecretInput, { target: { value: 'valid-client-secret' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockProps.onSuccess).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||||
|
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||||
|
import Drawer from '@/app/components/base/drawer-plus'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import Toast from '@/app/components/base/toast'
|
||||||
|
import Loading from '@/app/components/base/loading'
|
||||||
|
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
||||||
|
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||||
|
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||||
|
import {
|
||||||
|
useBuildTriggerSubscription,
|
||||||
|
useCreateTriggerSubscriptionBuilder,
|
||||||
|
useInvalidateTriggerSubscriptions,
|
||||||
|
useUpdateTriggerSubscriptionBuilder,
|
||||||
|
useVerifyTriggerSubscriptionBuilder,
|
||||||
|
} from '@/service/use-triggers'
|
||||||
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
|
import { findMissingRequiredField, sanitizeFormValues } from '../utils/form-helpers'
|
||||||
|
|
||||||
|
type ApiKeyConfigModalProps = {
|
||||||
|
provider: TriggerWithProvider
|
||||||
|
onCancel: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiKeyConfigModal: FC<ApiKeyConfigModalProps> = ({
|
||||||
|
provider,
|
||||||
|
onCancel,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { notify } = useToastContext()
|
||||||
|
const language = useLanguage()
|
||||||
|
const [credentialSchema, setCredentialSchema] = useState<any[]>([])
|
||||||
|
const [tempCredential, setTempCredential] = useState<Record<string, any>>({})
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [subscriptionBuilderId, setSubscriptionBuilderId] = useState<string>('')
|
||||||
|
|
||||||
|
const createBuilder = useCreateTriggerSubscriptionBuilder()
|
||||||
|
const updateBuilder = useUpdateTriggerSubscriptionBuilder()
|
||||||
|
const verifyBuilder = useVerifyTriggerSubscriptionBuilder()
|
||||||
|
const buildSubscription = useBuildTriggerSubscription()
|
||||||
|
const invalidateSubscriptions = useInvalidateTriggerSubscriptions()
|
||||||
|
|
||||||
|
const providerPath = `${provider.plugin_id}/${provider.name}`
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (provider.credentials_schema) {
|
||||||
|
const schemas = toolCredentialToFormSchemas(provider.credentials_schema as any)
|
||||||
|
setCredentialSchema(schemas)
|
||||||
|
const defaultCredentials = addDefaultValue({}, schemas)
|
||||||
|
// Use utility function for consistent data sanitization
|
||||||
|
setTempCredential(sanitizeFormValues(defaultCredentials))
|
||||||
|
}
|
||||||
|
}, [provider.credentials_schema])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Validate required fields using utility function
|
||||||
|
const requiredFields = credentialSchema
|
||||||
|
.filter(field => field.required)
|
||||||
|
.map(field => ({
|
||||||
|
name: field.name,
|
||||||
|
label: field.label[language] || field.label.en_US,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const missingField = findMissingRequiredField(tempCredential, requiredFields)
|
||||||
|
if (missingField) {
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: t('common.errorMsg.fieldRequired', {
|
||||||
|
field: missingField.label,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Create subscription builder
|
||||||
|
let builderId = subscriptionBuilderId
|
||||||
|
if (!builderId) {
|
||||||
|
const createResponse = await createBuilder.mutateAsync({
|
||||||
|
provider: providerPath,
|
||||||
|
credentials: tempCredential,
|
||||||
|
})
|
||||||
|
builderId = createResponse.subscription_builder.id
|
||||||
|
setSubscriptionBuilderId(builderId)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Update existing builder
|
||||||
|
await updateBuilder.mutateAsync({
|
||||||
|
provider: providerPath,
|
||||||
|
subscriptionBuilderId: builderId,
|
||||||
|
credentials: tempCredential,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Verify credentials
|
||||||
|
await verifyBuilder.mutateAsync({
|
||||||
|
provider: providerPath,
|
||||||
|
subscriptionBuilderId: builderId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 3: Build final subscription
|
||||||
|
await buildSubscription.mutateAsync({
|
||||||
|
provider: providerPath,
|
||||||
|
subscriptionBuilderId: builderId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 4: Invalidate and notify success
|
||||||
|
invalidateSubscriptions(providerPath)
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('workflow.nodes.triggerPlugin.apiKeyConfigured'),
|
||||||
|
})
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
notify({
|
||||||
|
type: 'error',
|
||||||
|
message: t('workflow.nodes.triggerPlugin.configurationFailed', { error: error.message }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
isShow
|
||||||
|
onHide={onCancel}
|
||||||
|
title={t('workflow.nodes.triggerPlugin.configureApiKey')}
|
||||||
|
titleDescription={t('workflow.nodes.triggerPlugin.apiKeyDescription')}
|
||||||
|
panelClassName='mt-[64px] mb-2 !w-[420px] border-components-panel-border'
|
||||||
|
maxWidthClassName='!max-w-[420px]'
|
||||||
|
height='calc(100vh - 64px)'
|
||||||
|
contentClassName='!bg-components-panel-bg'
|
||||||
|
headerClassName='!border-b-divider-subtle'
|
||||||
|
body={
|
||||||
|
<div className='h-full px-6 py-3'>
|
||||||
|
{credentialSchema.length === 0 ? (
|
||||||
|
<Loading type='app' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Form
|
||||||
|
value={tempCredential}
|
||||||
|
onChange={setTempCredential}
|
||||||
|
formSchemas={credentialSchema}
|
||||||
|
isEditMode={true}
|
||||||
|
showOnVariableMap={{}}
|
||||||
|
validating={false}
|
||||||
|
inputClassName='!bg-components-input-bg-normal'
|
||||||
|
fieldMoreInfo={item => item.url ? (
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='inline-flex items-center text-xs text-text-accent'
|
||||||
|
>
|
||||||
|
{t('tools.howToGet')}
|
||||||
|
<LinkExternal02 className='ml-1 h-3 w-3' />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
/>
|
||||||
|
<div className='mt-4 flex justify-end space-x-2'>
|
||||||
|
<Button onClick={onCancel}>
|
||||||
|
{t('common.operation.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant='primary'
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{t('common.operation.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
isShowMask={true}
|
||||||
|
clickOutsideNotOpen={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(ApiKeyConfigModal)
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiEqualizer2Line } from '@remixicon/react'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||||
|
import {
|
||||||
|
useInitiateTriggerOAuth,
|
||||||
|
useInvalidateTriggerSubscriptions,
|
||||||
|
useTriggerOAuthConfig,
|
||||||
|
} from '@/service/use-triggers'
|
||||||
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
|
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||||
|
import ApiKeyConfigModal from './api-key-config-modal'
|
||||||
|
import OAuthClientConfigModal from './oauth-client-config-modal'
|
||||||
|
|
||||||
|
type AuthMethodSelectorProps = {
|
||||||
|
provider: TriggerWithProvider
|
||||||
|
supportedMethods: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthMethodSelector: FC<AuthMethodSelectorProps> = ({
|
||||||
|
provider,
|
||||||
|
supportedMethods,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { notify } = useToastContext()
|
||||||
|
const [showApiKeyModal, setShowApiKeyModal] = useState(false)
|
||||||
|
const [showOAuthClientModal, setShowOAuthClientModal] = useState(false)
|
||||||
|
const initiateTriggerOAuth = useInitiateTriggerOAuth()
|
||||||
|
const invalidateSubscriptions = useInvalidateTriggerSubscriptions()
|
||||||
|
|
||||||
|
const providerPath = `${provider.plugin_id}/${provider.name}`
|
||||||
|
const { data: oauthConfig } = useTriggerOAuthConfig(providerPath, supportedMethods.includes('oauth'))
|
||||||
|
|
||||||
|
const handleOAuthAuth = useCallback(async () => {
|
||||||
|
// Check if OAuth client is configured
|
||||||
|
if (!oauthConfig?.custom_configured || !oauthConfig?.custom_enabled) {
|
||||||
|
// Need to configure OAuth client first
|
||||||
|
setShowOAuthClientModal(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await initiateTriggerOAuth.mutateAsync(providerPath)
|
||||||
|
if (response.authorization_url) {
|
||||||
|
openOAuthPopup(response.authorization_url, (callbackData) => {
|
||||||
|
invalidateSubscriptions(providerPath)
|
||||||
|
|
||||||
|
if (callbackData?.success === false) {
|
||||||
|
notify({
|
||||||
|
type: 'error',
|
||||||
|
message: callbackData.errorDescription || callbackData.error || t('workflow.nodes.triggerPlugin.authenticationFailed'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else if (callbackData?.subscriptionId) {
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('workflow.nodes.triggerPlugin.authenticationSuccess'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
notify({
|
||||||
|
type: 'error',
|
||||||
|
message: t('workflow.nodes.triggerPlugin.oauthConfigFailed', { error: error.message }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [providerPath, initiateTriggerOAuth, invalidateSubscriptions, notify, oauthConfig])
|
||||||
|
|
||||||
|
const handleApiKeyAuth = useCallback(() => {
|
||||||
|
setShowApiKeyModal(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!supportedMethods.includes('oauth') && !supportedMethods.includes('api_key'))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<div className="flex w-full items-center">
|
||||||
|
{/* OAuth Button Group */}
|
||||||
|
{supportedMethods.includes('oauth') && (
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="medium"
|
||||||
|
onClick={handleOAuthAuth}
|
||||||
|
className="flex-1 rounded-r-none"
|
||||||
|
>
|
||||||
|
{t('workflow.nodes.triggerPlugin.useOAuth')}
|
||||||
|
</Button>
|
||||||
|
<div className="h-4 w-px bg-text-primary-on-surface opacity-15" />
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="medium"
|
||||||
|
className="min-w-0 rounded-l-none px-2"
|
||||||
|
onClick={() => setShowOAuthClientModal(true)}
|
||||||
|
>
|
||||||
|
<RiEqualizer2Line className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider with OR */}
|
||||||
|
{supportedMethods.includes('oauth') && supportedMethods.includes('api_key') && (
|
||||||
|
<div className="flex h-8 flex-col items-center justify-center px-1">
|
||||||
|
<div className="h-2 w-px bg-divider-subtle" />
|
||||||
|
<span className="px-1 text-xs font-medium text-text-tertiary">{t('workflow.nodes.triggerPlugin.or')}</span>
|
||||||
|
<div className="h-2 w-px bg-divider-subtle" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API Key Button */}
|
||||||
|
{supportedMethods.includes('api_key') && (
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<Button
|
||||||
|
variant="secondary-accent"
|
||||||
|
size="medium"
|
||||||
|
onClick={handleApiKeyAuth}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t('workflow.nodes.triggerPlugin.useApiKey')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key Configuration Modal */}
|
||||||
|
{showApiKeyModal && (
|
||||||
|
<ApiKeyConfigModal
|
||||||
|
provider={provider}
|
||||||
|
onCancel={() => setShowApiKeyModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowApiKeyModal(false)
|
||||||
|
invalidateSubscriptions(providerPath)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OAuth Client Configuration Modal */}
|
||||||
|
{showOAuthClientModal && (
|
||||||
|
<OAuthClientConfigModal
|
||||||
|
provider={provider}
|
||||||
|
onCancel={() => setShowOAuthClientModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowOAuthClientModal(false)
|
||||||
|
// After OAuth client configuration, proceed with OAuth auth
|
||||||
|
handleOAuthAuth()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthMethodSelector
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||||
|
import type { TriggerOAuthClientParams, TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||||
|
import Drawer from '@/app/components/base/drawer-plus'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import Toast from '@/app/components/base/toast'
|
||||||
|
import Loading from '@/app/components/base/loading'
|
||||||
|
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
||||||
|
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||||
|
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||||
|
import {
|
||||||
|
useConfigureTriggerOAuth,
|
||||||
|
useInvalidateTriggerOAuthConfig,
|
||||||
|
useTriggerOAuthConfig,
|
||||||
|
} from '@/service/use-triggers'
|
||||||
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
|
import { findMissingRequiredField, sanitizeFormValues } from '../utils/form-helpers'
|
||||||
|
|
||||||
|
// Type-safe conversion function for dynamic OAuth client parameters
|
||||||
|
const convertToOAuthClientParams = (credentials: Record<string, any>): TriggerOAuthClientParams => {
|
||||||
|
// Use utility function for consistent data sanitization
|
||||||
|
const sanitizedCredentials = sanitizeFormValues(credentials)
|
||||||
|
|
||||||
|
// Create base params with required fields
|
||||||
|
const baseParams: TriggerOAuthClientParams = {
|
||||||
|
client_id: sanitizedCredentials.client_id || '',
|
||||||
|
client_secret: sanitizedCredentials.client_secret || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add optional fields if they exist
|
||||||
|
if (sanitizedCredentials.authorization_url)
|
||||||
|
baseParams.authorization_url = sanitizedCredentials.authorization_url
|
||||||
|
if (sanitizedCredentials.token_url)
|
||||||
|
baseParams.token_url = sanitizedCredentials.token_url
|
||||||
|
if (sanitizedCredentials.scope)
|
||||||
|
baseParams.scope = sanitizedCredentials.scope
|
||||||
|
|
||||||
|
return baseParams
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuthClientConfigModalProps = {
|
||||||
|
provider: TriggerWithProvider
|
||||||
|
onCancel: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const OAuthClientConfigModal: FC<OAuthClientConfigModalProps> = ({
|
||||||
|
provider,
|
||||||
|
onCancel,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { notify } = useToastContext()
|
||||||
|
const language = useLanguage()
|
||||||
|
const [credentialSchema, setCredentialSchema] = useState<any[]>([])
|
||||||
|
const [tempCredential, setTempCredential] = useState<Record<string, any>>({})
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const providerPath = `${provider.plugin_id}/${provider.name}`
|
||||||
|
|
||||||
|
const { data: oauthConfig, isLoading: isLoadingConfig } = useTriggerOAuthConfig(providerPath)
|
||||||
|
const configureTriggerOAuth = useConfigureTriggerOAuth()
|
||||||
|
const invalidateOAuthConfig = useInvalidateTriggerOAuthConfig()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (provider.oauth_client_schema) {
|
||||||
|
const schemas = toolCredentialToFormSchemas(provider.oauth_client_schema as any)
|
||||||
|
setCredentialSchema(schemas)
|
||||||
|
|
||||||
|
// Load existing configuration if available, ensure no null values
|
||||||
|
const existingParams = oauthConfig?.params || {}
|
||||||
|
const defaultCredentials = addDefaultValue(existingParams, schemas)
|
||||||
|
|
||||||
|
// Use utility function for consistent data sanitization
|
||||||
|
setTempCredential(sanitizeFormValues(defaultCredentials))
|
||||||
|
}
|
||||||
|
}, [provider.oauth_client_schema, oauthConfig])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Validate required fields using utility function
|
||||||
|
const requiredFields = credentialSchema
|
||||||
|
.filter(field => field.required)
|
||||||
|
.map(field => ({
|
||||||
|
name: field.name,
|
||||||
|
label: field.label[language] || field.label.en_US,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const missingField = findMissingRequiredField(tempCredential, requiredFields)
|
||||||
|
if (missingField) {
|
||||||
|
Toast.notify({
|
||||||
|
type: 'error',
|
||||||
|
message: t('common.errorMsg.fieldRequired', {
|
||||||
|
field: missingField.label,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configureTriggerOAuth.mutateAsync({
|
||||||
|
provider: providerPath,
|
||||||
|
client_params: convertToOAuthClientParams(tempCredential),
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
invalidateOAuthConfig(providerPath)
|
||||||
|
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('workflow.nodes.triggerPlugin.oauthClientSaved'),
|
||||||
|
})
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
catch (error: any) {
|
||||||
|
notify({
|
||||||
|
type: 'error',
|
||||||
|
message: t('workflow.nodes.triggerPlugin.configurationFailed', { error: error.message }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
isShow
|
||||||
|
onHide={onCancel}
|
||||||
|
title={t('workflow.nodes.triggerPlugin.configureOAuthClient')}
|
||||||
|
titleDescription={t('workflow.nodes.triggerPlugin.oauthClientDescription')}
|
||||||
|
panelClassName='mt-[64px] mb-2 !w-[420px] border-components-panel-border'
|
||||||
|
maxWidthClassName='!max-w-[420px]'
|
||||||
|
height='calc(100vh - 64px)'
|
||||||
|
contentClassName='!bg-components-panel-bg'
|
||||||
|
headerClassName='!border-b-divider-subtle'
|
||||||
|
body={
|
||||||
|
<div className='h-full px-6 py-3'>
|
||||||
|
{isLoadingConfig || credentialSchema.length === 0 ? (
|
||||||
|
<Loading type='app' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Form
|
||||||
|
value={tempCredential}
|
||||||
|
onChange={(value) => {
|
||||||
|
// Use utility function for consistent data sanitization
|
||||||
|
setTempCredential(sanitizeFormValues(value))
|
||||||
|
}}
|
||||||
|
formSchemas={credentialSchema}
|
||||||
|
isEditMode={true}
|
||||||
|
showOnVariableMap={{}}
|
||||||
|
validating={false}
|
||||||
|
inputClassName='!bg-components-input-bg-normal'
|
||||||
|
fieldMoreInfo={item => item.url ? (
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='inline-flex items-center text-xs text-text-accent'
|
||||||
|
>
|
||||||
|
{t('tools.howToGet')}
|
||||||
|
<LinkExternal02 className='ml-1 h-3 w-3' />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
/>
|
||||||
|
<div className='mt-4 flex justify-end space-x-2'>
|
||||||
|
<Button onClick={onCancel}>
|
||||||
|
{t('common.operation.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant='primary'
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{t('common.operation.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
isShowMask={true}
|
||||||
|
clickOutsideNotOpen={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(OAuthClientConfigModal)
|
||||||
@ -103,6 +103,21 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => {
|
|||||||
|
|
||||||
const showAuthRequired = !isAuthenticated && !!currentProvider
|
const showAuthRequired = !isAuthenticated && !!currentProvider
|
||||||
|
|
||||||
|
// Check supported authentication methods
|
||||||
|
const supportedAuthMethods = useMemo(() => {
|
||||||
|
if (!currentProvider) return []
|
||||||
|
|
||||||
|
const methods = []
|
||||||
|
|
||||||
|
if (currentProvider.oauth_client_schema && currentProvider.oauth_client_schema.length > 0)
|
||||||
|
methods.push('oauth')
|
||||||
|
|
||||||
|
if (currentProvider.credentials_schema && currentProvider.credentials_schema.length > 0)
|
||||||
|
methods.push('api_key')
|
||||||
|
|
||||||
|
return methods
|
||||||
|
}, [currentProvider])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
readOnly,
|
readOnly,
|
||||||
inputs,
|
inputs,
|
||||||
@ -116,6 +131,7 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => {
|
|||||||
hasObjectOutput,
|
hasObjectOutput,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
showAuthRequired,
|
showAuthRequired,
|
||||||
|
supportedAuthMethods,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,308 @@
|
|||||||
|
import { deepSanitizeFormValues, findMissingRequiredField, sanitizeFormValues } from '../form-helpers'
|
||||||
|
|
||||||
|
describe('Form Helpers', () => {
|
||||||
|
describe('sanitizeFormValues', () => {
|
||||||
|
it('should convert null values to empty strings', () => {
|
||||||
|
const input = { field1: null, field2: 'value', field3: undefined }
|
||||||
|
const result = sanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
field1: '',
|
||||||
|
field2: 'value',
|
||||||
|
field3: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should convert undefined values to empty strings', () => {
|
||||||
|
const input = { field1: undefined, field2: 'test' }
|
||||||
|
const result = sanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
field1: '',
|
||||||
|
field2: 'test',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should convert non-string values to strings', () => {
|
||||||
|
const input = { number: 123, boolean: true, string: 'test' }
|
||||||
|
const result = sanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
number: '123',
|
||||||
|
boolean: 'true',
|
||||||
|
string: 'test',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty objects', () => {
|
||||||
|
const result = sanitizeFormValues({})
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle objects with mixed value types', () => {
|
||||||
|
const input = {
|
||||||
|
null_field: null,
|
||||||
|
undefined_field: undefined,
|
||||||
|
zero: 0,
|
||||||
|
false_field: false,
|
||||||
|
empty_string: '',
|
||||||
|
valid_string: 'test',
|
||||||
|
}
|
||||||
|
const result = sanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
null_field: '',
|
||||||
|
undefined_field: '',
|
||||||
|
zero: '0',
|
||||||
|
false_field: 'false',
|
||||||
|
empty_string: '',
|
||||||
|
valid_string: 'test',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deepSanitizeFormValues', () => {
|
||||||
|
it('should handle nested objects', () => {
|
||||||
|
const input = {
|
||||||
|
level1: {
|
||||||
|
field1: null,
|
||||||
|
field2: 'value',
|
||||||
|
level2: {
|
||||||
|
field3: undefined,
|
||||||
|
field4: 'nested',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
simple: 'test',
|
||||||
|
}
|
||||||
|
const result = deepSanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
level1: {
|
||||||
|
field1: '',
|
||||||
|
field2: 'value',
|
||||||
|
level2: {
|
||||||
|
field3: '',
|
||||||
|
field4: 'nested',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
simple: 'test',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle arrays correctly', () => {
|
||||||
|
const input = {
|
||||||
|
array: [1, 2, 3],
|
||||||
|
nested: {
|
||||||
|
array: ['a', null, 'c'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = deepSanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
array: [1, 2, 3],
|
||||||
|
nested: {
|
||||||
|
array: ['a', null, 'c'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle null and undefined at root level', () => {
|
||||||
|
const input = {
|
||||||
|
null_field: null,
|
||||||
|
undefined_field: undefined,
|
||||||
|
nested: {
|
||||||
|
null_nested: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = deepSanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
null_field: '',
|
||||||
|
undefined_field: '',
|
||||||
|
nested: {
|
||||||
|
null_nested: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle deeply nested structures', () => {
|
||||||
|
const input = {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
level3: {
|
||||||
|
field: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = deepSanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
level3: {
|
||||||
|
field: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve non-null values in nested structures', () => {
|
||||||
|
const input = {
|
||||||
|
config: {
|
||||||
|
client_id: 'valid_id',
|
||||||
|
client_secret: null,
|
||||||
|
options: {
|
||||||
|
timeout: 5000,
|
||||||
|
enabled: true,
|
||||||
|
message: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const result = deepSanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
config: {
|
||||||
|
client_id: 'valid_id',
|
||||||
|
client_secret: '',
|
||||||
|
options: {
|
||||||
|
timeout: 5000,
|
||||||
|
enabled: true,
|
||||||
|
message: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findMissingRequiredField', () => {
|
||||||
|
const requiredFields = [
|
||||||
|
{ name: 'client_id', label: 'Client ID' },
|
||||||
|
{ name: 'client_secret', label: 'Client Secret' },
|
||||||
|
{ name: 'scope', label: 'Scope' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('should return null when all required fields are present', () => {
|
||||||
|
const formData = {
|
||||||
|
client_id: 'test_id',
|
||||||
|
client_secret: 'test_secret',
|
||||||
|
scope: 'read',
|
||||||
|
optional_field: 'optional',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = findMissingRequiredField(formData, requiredFields)
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the first missing field', () => {
|
||||||
|
const formData = {
|
||||||
|
client_id: 'test_id',
|
||||||
|
scope: 'read',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = findMissingRequiredField(formData, requiredFields)
|
||||||
|
expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should treat empty strings as missing fields', () => {
|
||||||
|
const formData = {
|
||||||
|
client_id: '',
|
||||||
|
client_secret: 'test_secret',
|
||||||
|
scope: 'read',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = findMissingRequiredField(formData, requiredFields)
|
||||||
|
expect(result).toEqual({ name: 'client_id', label: 'Client ID' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should treat null values as missing fields', () => {
|
||||||
|
const formData = {
|
||||||
|
client_id: 'test_id',
|
||||||
|
client_secret: null,
|
||||||
|
scope: 'read',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = findMissingRequiredField(formData, requiredFields)
|
||||||
|
expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should treat undefined values as missing fields', () => {
|
||||||
|
const formData = {
|
||||||
|
client_id: 'test_id',
|
||||||
|
client_secret: 'test_secret',
|
||||||
|
scope: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = findMissingRequiredField(formData, requiredFields)
|
||||||
|
expect(result).toEqual({ name: 'scope', label: 'Scope' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty required fields array', () => {
|
||||||
|
const formData = {
|
||||||
|
client_id: 'test_id',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = findMissingRequiredField(formData, [])
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty form data', () => {
|
||||||
|
const result = findMissingRequiredField({}, requiredFields)
|
||||||
|
expect(result).toEqual({ name: 'client_id', label: 'Client ID' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multilingual labels', () => {
|
||||||
|
const multilingualFields = [
|
||||||
|
{ name: 'field1', label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' } },
|
||||||
|
]
|
||||||
|
const formData = {}
|
||||||
|
|
||||||
|
const result = findMissingRequiredField(formData, multilingualFields)
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: 'field1',
|
||||||
|
label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for form data with extra fields', () => {
|
||||||
|
const formData = {
|
||||||
|
client_id: 'test_id',
|
||||||
|
client_secret: 'test_secret',
|
||||||
|
scope: 'read',
|
||||||
|
extra_field1: 'extra1',
|
||||||
|
extra_field2: 'extra2',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = findMissingRequiredField(formData, requiredFields)
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle objects with non-string keys', () => {
|
||||||
|
const input = { [Symbol('test')]: 'value', regular: 'field' } as any
|
||||||
|
const result = sanitizeFormValues(input)
|
||||||
|
|
||||||
|
expect(result.regular).toBe('field')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle objects with getter properties', () => {
|
||||||
|
const obj = {}
|
||||||
|
Object.defineProperty(obj, 'getter', {
|
||||||
|
get: () => 'computed_value',
|
||||||
|
enumerable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = sanitizeFormValues(obj)
|
||||||
|
expect(result.getter).toBe('computed_value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle circular references in deepSanitizeFormValues gracefully', () => {
|
||||||
|
const obj: any = { field: 'value' }
|
||||||
|
obj.circular = obj
|
||||||
|
|
||||||
|
expect(() => deepSanitizeFormValues(obj)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for form data handling in trigger plugin components
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes form values by converting null/undefined to empty strings
|
||||||
|
* This ensures React form inputs don't receive null values which can cause warnings
|
||||||
|
*/
|
||||||
|
export const sanitizeFormValues = (values: Record<string, any>): Record<string, string> => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(values).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
value === null || value === undefined ? '' : String(value),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep sanitizes form values while preserving nested objects structure
|
||||||
|
* Useful for complex form schemas with nested properties
|
||||||
|
*/
|
||||||
|
export const deepSanitizeFormValues = (values: Record<string, any>, visited = new WeakSet()): Record<string, any> => {
|
||||||
|
if (visited.has(values))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
visited.add(values)
|
||||||
|
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (value === null || value === undefined)
|
||||||
|
result[key] = ''
|
||||||
|
else if (typeof value === 'object' && !Array.isArray(value))
|
||||||
|
result[key] = deepSanitizeFormValues(value, visited)
|
||||||
|
else
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates required fields in form data
|
||||||
|
* Returns the first missing required field or null if all are present
|
||||||
|
*/
|
||||||
|
export const findMissingRequiredField = (
|
||||||
|
formData: Record<string, any>,
|
||||||
|
requiredFields: Array<{ name: string; label: any }>,
|
||||||
|
): { name: string; label: any } | null => {
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!formData[field.name] || formData[field.name] === '')
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@ -10,8 +10,13 @@ import type { WebhookTriggerNodeType } from '../types'
|
|||||||
|
|
||||||
// Simple mock translation function
|
// Simple mock translation function
|
||||||
const mockT = (key: string, params?: any) => {
|
const mockT = (key: string, params?: any) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'workflow.nodes.triggerWebhook.validation.webhookUrlRequired': 'Webhook URL is required',
|
||||||
|
'workflow.nodes.triggerWebhook.validation.invalidParameterType': `Invalid parameter type ${params?.type} for ${params?.name}`,
|
||||||
|
}
|
||||||
|
|
||||||
if (key.includes('fieldRequired')) return `${params?.field} is required`
|
if (key.includes('fieldRequired')) return `${params?.field} is required`
|
||||||
return key
|
return translations[key] || key
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Webhook Trigger Node Default', () => {
|
describe('Webhook Trigger Node Default', () => {
|
||||||
@ -53,17 +58,29 @@ describe('Webhook Trigger Node Default', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Validation - checkValid', () => {
|
describe('Validation - checkValid', () => {
|
||||||
it('should validate successfully with default configuration', () => {
|
it('should require webhook_url to be configured', () => {
|
||||||
const payload = nodeDefault.defaultValue as WebhookTriggerNodeType
|
const payload = nodeDefault.defaultValue as WebhookTriggerNodeType
|
||||||
|
|
||||||
|
const result = nodeDefault.checkValid(payload, mockT)
|
||||||
|
expect(result.isValid).toBe(false)
|
||||||
|
expect(result.errorMessage).toContain('required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate successfully when webhook_url is provided', () => {
|
||||||
|
const payload = {
|
||||||
|
...nodeDefault.defaultValue,
|
||||||
|
webhook_url: 'https://example.com/webhook',
|
||||||
|
} as WebhookTriggerNodeType
|
||||||
|
|
||||||
const result = nodeDefault.checkValid(payload, mockT)
|
const result = nodeDefault.checkValid(payload, mockT)
|
||||||
expect(result.isValid).toBe(true)
|
expect(result.isValid).toBe(true)
|
||||||
expect(result.errorMessage).toBe('')
|
expect(result.errorMessage).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle response configuration fields', () => {
|
it('should handle response configuration fields when webhook_url is provided', () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
...nodeDefault.defaultValue,
|
...nodeDefault.defaultValue,
|
||||||
|
webhook_url: 'https://example.com/webhook',
|
||||||
status_code: 404,
|
status_code: 404,
|
||||||
response_body: '{"error": "Not found"}',
|
response_body: '{"error": "Not found"}',
|
||||||
} as WebhookTriggerNodeType
|
} as WebhookTriggerNodeType
|
||||||
@ -72,9 +89,10 @@ describe('Webhook Trigger Node Default', () => {
|
|||||||
expect(result.isValid).toBe(true)
|
expect(result.isValid).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle async_mode field correctly', () => {
|
it('should handle async_mode field correctly when webhook_url is provided', () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
...nodeDefault.defaultValue,
|
...nodeDefault.defaultValue,
|
||||||
|
webhook_url: 'https://example.com/webhook',
|
||||||
async_mode: false,
|
async_mode: false,
|
||||||
} as WebhookTriggerNodeType
|
} as WebhookTriggerNodeType
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,9 @@ export const normalizeParameterType = (input: string | undefined | null): VarTyp
|
|||||||
return VarType.arrayBoolean
|
return VarType.arrayBoolean
|
||||||
else if (trimmed === 'array[object]')
|
else if (trimmed === 'array[object]')
|
||||||
return VarType.arrayObject
|
return VarType.arrayObject
|
||||||
|
else if (trimmed === 'array')
|
||||||
|
// Migrate legacy 'array' type to 'array[string]'
|
||||||
|
return VarType.arrayString
|
||||||
else if (trimmed === 'number')
|
else if (trimmed === 'number')
|
||||||
return VarType.number
|
return VarType.number
|
||||||
else if (trimmed === 'boolean')
|
else if (trimmed === 'boolean')
|
||||||
|
|||||||
@ -3,16 +3,38 @@ import { useEffect } from 'react'
|
|||||||
|
|
||||||
export const useOAuthCallback = () => {
|
export const useOAuthCallback = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const subscriptionId = urlParams.get('subscription_id')
|
||||||
|
const error = urlParams.get('error')
|
||||||
|
const errorDescription = urlParams.get('error_description')
|
||||||
|
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
window.opener.postMessage({
|
if (subscriptionId) {
|
||||||
type: 'oauth_callback',
|
window.opener.postMessage({
|
||||||
}, '*')
|
type: 'oauth_callback',
|
||||||
|
success: true,
|
||||||
|
subscriptionId,
|
||||||
|
}, '*')
|
||||||
|
}
|
||||||
|
else if (error) {
|
||||||
|
window.opener.postMessage({
|
||||||
|
type: 'oauth_callback',
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
errorDescription,
|
||||||
|
}, '*')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
window.opener.postMessage({
|
||||||
|
type: 'oauth_callback',
|
||||||
|
}, '*')
|
||||||
|
}
|
||||||
window.close()
|
window.close()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
export const openOAuthPopup = (url: string, callback: () => void) => {
|
export const openOAuthPopup = (url: string, callback: (data?: any) => void) => {
|
||||||
const width = 600
|
const width = 600
|
||||||
const height = 600
|
const height = 600
|
||||||
const left = window.screenX + (window.outerWidth - width) / 2
|
const left = window.screenX + (window.outerWidth - width) / 2
|
||||||
@ -27,10 +49,20 @@ export const openOAuthPopup = (url: string, callback: () => void) => {
|
|||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
if (event.data?.type === 'oauth_callback') {
|
if (event.data?.type === 'oauth_callback') {
|
||||||
window.removeEventListener('message', handleMessage)
|
window.removeEventListener('message', handleMessage)
|
||||||
callback()
|
callback(event.data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage)
|
window.addEventListener('message', handleMessage)
|
||||||
|
|
||||||
|
// Fallback for window close detection
|
||||||
|
const checkClosed = setInterval(() => {
|
||||||
|
if (popup?.closed) {
|
||||||
|
clearInterval(checkClosed)
|
||||||
|
window.removeEventListener('message', handleMessage)
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
return popup
|
return popup
|
||||||
}
|
}
|
||||||
|
|||||||
@ -730,6 +730,19 @@ const translation = {
|
|||||||
error: 'Error',
|
error: 'Error',
|
||||||
configuration: 'Configuration',
|
configuration: 'Configuration',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
|
or: 'OR',
|
||||||
|
useOAuth: 'Use OAuth',
|
||||||
|
useApiKey: 'Use API Key',
|
||||||
|
authenticationFailed: 'Authentication failed',
|
||||||
|
authenticationSuccess: 'Authentication successful',
|
||||||
|
oauthConfigFailed: 'OAuth configuration failed',
|
||||||
|
configureOAuthClient: 'Configure OAuth Client',
|
||||||
|
oauthClientDescription: 'Configure OAuth client credentials to enable authentication',
|
||||||
|
oauthClientSaved: 'OAuth client configuration saved successfully',
|
||||||
|
configureApiKey: 'Configure API Key',
|
||||||
|
apiKeyDescription: 'Configure API key credentials for authentication',
|
||||||
|
apiKeyConfigured: 'API key configured successfully',
|
||||||
|
configurationFailed: 'Configuration failed',
|
||||||
},
|
},
|
||||||
questionClassifiers: {
|
questionClassifiers: {
|
||||||
model: 'model',
|
model: 'model',
|
||||||
|
|||||||
@ -1040,6 +1040,19 @@ const translation = {
|
|||||||
error: 'エラー',
|
error: 'エラー',
|
||||||
configuration: '構成',
|
configuration: '構成',
|
||||||
remove: '削除する',
|
remove: '削除する',
|
||||||
|
or: 'または',
|
||||||
|
useOAuth: 'OAuth を使用',
|
||||||
|
useApiKey: 'API キーを使用',
|
||||||
|
authenticationFailed: '認証に失敗しました',
|
||||||
|
authenticationSuccess: '認証に成功しました',
|
||||||
|
oauthConfigFailed: 'OAuth 設定に失敗しました',
|
||||||
|
configureOAuthClient: 'OAuth クライアントを設定',
|
||||||
|
oauthClientDescription: '認証を有効にするために OAuth クライアント認証情報を設定してください',
|
||||||
|
oauthClientSaved: 'OAuth クライアント設定が正常に保存されました',
|
||||||
|
configureApiKey: 'API キーを設定',
|
||||||
|
apiKeyDescription: '認証のための API キー認証情報を設定してください',
|
||||||
|
apiKeyConfigured: 'API キーが正常に設定されました',
|
||||||
|
configurationFailed: '設定に失敗しました',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tracing: {
|
tracing: {
|
||||||
|
|||||||
@ -1040,6 +1040,19 @@ const translation = {
|
|||||||
error: '错误',
|
error: '错误',
|
||||||
configuration: '配置',
|
configuration: '配置',
|
||||||
remove: '移除',
|
remove: '移除',
|
||||||
|
or: '或',
|
||||||
|
useOAuth: '使用 OAuth',
|
||||||
|
useApiKey: '使用 API Key',
|
||||||
|
authenticationFailed: '身份验证失败',
|
||||||
|
authenticationSuccess: '身份验证成功',
|
||||||
|
oauthConfigFailed: 'OAuth 配置失败',
|
||||||
|
configureOAuthClient: '配置 OAuth 客户端',
|
||||||
|
oauthClientDescription: '配置 OAuth 客户端凭据以启用身份验证',
|
||||||
|
oauthClientSaved: 'OAuth 客户端配置保存成功',
|
||||||
|
configureApiKey: '配置 API Key',
|
||||||
|
apiKeyDescription: '配置 API key 凭据进行身份验证',
|
||||||
|
apiKeyConfigured: 'API key 配置成功',
|
||||||
|
configurationFailed: '配置失败',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tracing: {
|
tracing: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user