From 98ba0236e6b9732df7f839f6a6989b6b309d5afe Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Sun, 7 Sep 2025 21:53:22 +0800 Subject: [PATCH] feat: implement trigger plugin authentication UI (#25310) --- .../use-dynamic-test-run-options.test.tsx | 487 ------------------ .../_base/components/workflow-panel/index.tsx | 64 ++- .../workflow-panel/node-auth-factory.tsx | 26 +- .../__tests__/api-key-config-modal.test.tsx | 185 +++++++ .../__tests__/auth-method-selector.test.tsx | 194 +++++++ .../oauth-client-config-modal.test.tsx | 204 ++++++++ .../components/api-key-config-modal.tsx | 194 +++++++ .../components/auth-method-selector.tsx | 159 ++++++ .../components/oauth-client-config-modal.tsx | 194 +++++++ .../nodes/trigger-plugin/use-config.ts | 16 + .../utils/__tests__/form-helpers.test.ts | 308 +++++++++++ .../trigger-plugin/utils/form-helpers.ts | 55 ++ .../trigger-webhook/__tests__/default.test.ts | 26 +- .../utils/parameter-type-utils.ts | 3 + web/hooks/use-oauth.ts | 42 +- web/i18n/en-US/workflow.ts | 13 + web/i18n/ja-JP/workflow.ts | 13 + web/i18n/zh-Hans/workflow.ts | 13 + 18 files changed, 1688 insertions(+), 508 deletions(-) delete mode 100644 web/__tests__/use-dynamic-test-run-options.test.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/components/__tests__/api-key-config-modal.test.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/components/__tests__/auth-method-selector.test.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/components/__tests__/oauth-client-config-modal.test.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/components/api-key-config-modal.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/components/auth-method-selector.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/components/oauth-client-config-modal.tsx create mode 100644 web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts create mode 100644 web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts diff --git a/web/__tests__/use-dynamic-test-run-options.test.tsx b/web/__tests__/use-dynamic-test-run-options.test.tsx deleted file mode 100644 index 4c63e2e449..0000000000 --- a/web/__tests__/use-dynamic-test-run-options.test.tsx +++ /dev/null @@ -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 = { - '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
Home
- } -}) - -jest.mock('@/app/components/base/icons/src/vender/workflow', () => ({ - Schedule: function MockSchedule({ className }: { className: string }) { - return
Schedule
- }, - WebhookLine: function MockWebhookLine({ className }: { className: string }) { - return
Webhook
- }, -})) - -jest.mock('@/app/components/base/app-icon', () => { - return function MockAppIcon({ icon, background, className }: any) { - return ( -
- AppIcon -
- ) - } -}) - -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) - }) - }) -}) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index c21e919196..bc8364924e 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -49,6 +49,8 @@ import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workfl import { useStore as useAppStore } from '@/app/components/app/store' import { useStore } from '@/app/components/workflow/store' 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 useLastRun from './last-run/use-last-run' import BeforeRunForm from '../before-run-form' @@ -236,11 +238,59 @@ const BasePanel: FC = ({ return buildInTools.find(item => canFindTool(item.id, 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 const needsAuth = useMemo(() => { return (data.type === BlockEnum.Tool && currCollection?.allow_delete) - || (data.type === BlockEnum.TriggerPlugin) - }, [data.type, currCollection?.allow_delete]) + || (data.type === BlockEnum.TriggerPlugin && isTriggerAuthenticated) + }, [data.type, currCollection?.allow_delete, isTriggerAuthenticated]) const handleAuthorizationItemClick = useCallback((credential_id: string) => { handleNodeDataUpdateWithSyncDraft({ @@ -419,7 +469,15 @@ const BasePanel: FC = ({ ) } { - !needsAuth && ( + shouldShowAuthSelector && ( + + ) + } + { + !needsAuth && data.type !== BlockEnum.TriggerPlugin && (
= ({ data, onAuthorizationChange }) => { + const { t } = useTranslation() const buildInTools = useStore(s => s.buildInTools) const { notify } = useToastContext() @@ -89,19 +92,24 @@ const NodeAuth: FC = ({ data, onAuthorizationChange }) => { if (!provider) return try { - // Directly initiate OAuth flow, backend will automatically create subscription builder const response = await initiateTriggerOAuth.mutateAsync(provider) if (response.authorization_url) { - // Open OAuth authorization window - const authWindow = window.open(response.authorization_url, 'oauth_authorization', 'width=600,height=600') + openOAuthPopup(response.authorization_url, (callbackData) => { + invalidateSubscriptions(provider) - // Monitor window closure and refresh subscription list - const checkClosed = setInterval(() => { - if (authWindow?.closed) { - clearInterval(checkClosed) - invalidateSubscriptions(provider) + if (callbackData?.success === false) { + notify({ + type: 'error', + message: callbackData.errorDescription || callbackData.error || t('workflow.nodes.triggerPlugin.authenticationFailed'), + }) } - }, 1000) + else if (callbackData?.subscriptionId) { + notify({ + type: 'success', + message: t('workflow.nodes.triggerPlugin.authenticationSuccess'), + }) + } + }) } } catch (error: any) { diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/__tests__/api-key-config-modal.test.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/__tests__/api-key-config-modal.test.tsx new file mode 100644 index 0000000000..2c7c0c506d --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/__tests__/api-key-config-modal.test.tsx @@ -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 ( +
+ {formSchemas.map((schema: any, index: number) => ( +
+ + onChange({ ...value, [schema.name]: e.target.value })} + /> +
+ ))} +
+ ) + } +}) + +import ApiKeyConfigModal from '../api-key-config-modal' + +const mockUseTranslation = useTranslation as jest.MockedFunction + +const mockTranslation = { + t: (key: string, params?: any) => { + const translations: Record = { + '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() + + 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() + + await waitFor(() => { + expect(screen.getByTestId('mock-form')).toBeInTheDocument() + }) + }) + + it('should render form fields with correct labels', async () => { + render() + + await waitFor(() => { + expect(screen.getByLabelText('API Key')).toBeInTheDocument() + }) + }) + + it('should render cancel and save buttons', async () => { + render() + + 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() + + 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() + + 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() + + 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) + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/__tests__/auth-method-selector.test.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/__tests__/auth-method-selector.test.tsx new file mode 100644 index 0000000000..fea1adba2d --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/__tests__/auth-method-selector.test.tsx @@ -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 ( +
+ +
+ ) + } +}) +jest.mock('../oauth-client-config-modal', () => { + return function MockOAuthClientConfigModal({ onCancel }: any) { + return ( +
+ +
+ ) + } +}) + +import AuthMethodSelector from '../auth-method-selector' + +const mockUseTranslation = useTranslation as jest.MockedFunction + +const mockTranslation = { + t: (key: string) => { + const translations: Record = { + '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( + , + ) + + expect(container.firstChild).toBeNull() + }) + + it('should render OAuth button when oauth is supported', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'Use OAuth' })).toBeInTheDocument() + }) + + it('should render API Key button when api_key is supported', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'Use API Key' })).toBeInTheDocument() + }) + + it('should render both buttons and OR divider when both methods are supported', () => { + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/__tests__/oauth-client-config-modal.test.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/__tests__/oauth-client-config-modal.test.tsx new file mode 100644 index 0000000000..60b7bb168e --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/__tests__/oauth-client-config-modal.test.tsx @@ -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 ( +
+ {formSchemas.map((schema: any, index: number) => ( +
+ + onChange({ ...value, [schema.name]: e.target.value })} + /> +
+ ))} +
+ ) + } +}) + +import OAuthClientConfigModal from '../oauth-client-config-modal' + +const mockUseTranslation = useTranslation as jest.MockedFunction + +const mockTranslation = { + t: (key: string, params?: any) => { + const translations: Record = { + '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() + + 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() + + await waitFor(() => { + expect(screen.getByTestId('mock-form')).toBeInTheDocument() + }) + }) + + it('should render form fields with correct labels', async () => { + render() + + await waitFor(() => { + expect(screen.getByLabelText('Client ID')).toBeInTheDocument() + expect(screen.getByLabelText('Client Secret')).toBeInTheDocument() + }) + }) + + it('should render cancel and save buttons', async () => { + render() + + 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() + + 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() + + 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() + + 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) + }) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/api-key-config-modal.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/api-key-config-modal.tsx new file mode 100644 index 0000000000..48fc2e9116 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/api-key-config-modal.tsx @@ -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 = ({ + provider, + onCancel, + onSuccess, +}) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const language = useLanguage() + const [credentialSchema, setCredentialSchema] = useState([]) + const [tempCredential, setTempCredential] = useState>({}) + const [isLoading, setIsLoading] = useState(false) + const [subscriptionBuilderId, setSubscriptionBuilderId] = useState('') + + 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 ( + + {credentialSchema.length === 0 ? ( + + ) : ( + <> +
item.url ? ( + + {t('tools.howToGet')} + + + ) : null} + /> +
+ + +
+ + )} +
+ } + isShowMask={true} + clickOutsideNotOpen={false} + /> + ) +} + +export default React.memo(ApiKeyConfigModal) diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/auth-method-selector.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/auth-method-selector.tsx new file mode 100644 index 0000000000..4ac4347095 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/auth-method-selector.tsx @@ -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 = ({ + 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 ( +
+
+ {/* OAuth Button Group */} + {supportedMethods.includes('oauth') && ( +
+ +
+ +
+ )} + + {/* Divider with OR */} + {supportedMethods.includes('oauth') && supportedMethods.includes('api_key') && ( +
+
+ {t('workflow.nodes.triggerPlugin.or')} +
+
+ )} + + {/* API Key Button */} + {supportedMethods.includes('api_key') && ( +
+ +
+ )} +
+ + {/* API Key Configuration Modal */} + {showApiKeyModal && ( + setShowApiKeyModal(false)} + onSuccess={() => { + setShowApiKeyModal(false) + invalidateSubscriptions(providerPath) + }} + /> + )} + + {/* OAuth Client Configuration Modal */} + {showOAuthClientModal && ( + setShowOAuthClientModal(false)} + onSuccess={() => { + setShowOAuthClientModal(false) + // After OAuth client configuration, proceed with OAuth auth + handleOAuthAuth() + }} + /> + )} +
+ ) +} + +export default AuthMethodSelector diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/oauth-client-config-modal.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/oauth-client-config-modal.tsx new file mode 100644 index 0000000000..882989f56d --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/oauth-client-config-modal.tsx @@ -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): 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 = ({ + provider, + onCancel, + onSuccess, +}) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const language = useLanguage() + const [credentialSchema, setCredentialSchema] = useState([]) + const [tempCredential, setTempCredential] = useState>({}) + 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 ( + + {isLoadingConfig || credentialSchema.length === 0 ? ( + + ) : ( + <> + { + // 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 ? ( + + {t('tools.howToGet')} + + + ) : null} + /> +
+ + +
+ + )} +
+ } + isShowMask={true} + clickOutsideNotOpen={false} + /> + ) +} + +export default React.memo(OAuthClientConfigModal) diff --git a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts index f212d3c0f0..c4baa3683b 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts @@ -103,6 +103,21 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { 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 { readOnly, inputs, @@ -116,6 +131,7 @@ const useConfig = (id: string, payload: PluginTriggerNodeType) => { hasObjectOutput, isAuthenticated, showAuthRequired, + supportedAuthMethods, } } diff --git a/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts new file mode 100644 index 0000000000..c75ffc0a59 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/utils/__tests__/form-helpers.test.ts @@ -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() + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts b/web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts new file mode 100644 index 0000000000..36090d9771 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/utils/form-helpers.ts @@ -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): Record => { + 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, visited = new WeakSet()): Record => { + if (visited.has(values)) + return {} + + visited.add(values) + + const result: Record = {} + + 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, + 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 +} diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/default.test.ts b/web/app/components/workflow/nodes/trigger-webhook/__tests__/default.test.ts index 649b1a5286..e803e18174 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/__tests__/default.test.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/default.test.ts @@ -10,8 +10,13 @@ import type { WebhookTriggerNodeType } from '../types' // Simple mock translation function const mockT = (key: string, params?: any) => { + const translations: Record = { + '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` - return key + return translations[key] || key } describe('Webhook Trigger Node Default', () => { @@ -53,17 +58,29 @@ describe('Webhook Trigger Node Default', () => { }) 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 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) expect(result.isValid).toBe(true) expect(result.errorMessage).toBe('') }) - it('should handle response configuration fields', () => { + it('should handle response configuration fields when webhook_url is provided', () => { const payload = { ...nodeDefault.defaultValue, + webhook_url: 'https://example.com/webhook', status_code: 404, response_body: '{"error": "Not found"}', } as WebhookTriggerNodeType @@ -72,9 +89,10 @@ describe('Webhook Trigger Node Default', () => { 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 = { ...nodeDefault.defaultValue, + webhook_url: 'https://example.com/webhook', async_mode: false, } as WebhookTriggerNodeType diff --git a/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts index f71b2f96d5..14b41b1349 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/utils/parameter-type-utils.ts @@ -72,6 +72,9 @@ export const normalizeParameterType = (input: string | undefined | null): VarTyp return VarType.arrayBoolean else if (trimmed === 'array[object]') return VarType.arrayObject + else if (trimmed === 'array') + // Migrate legacy 'array' type to 'array[string]' + return VarType.arrayString else if (trimmed === 'number') return VarType.number else if (trimmed === 'boolean') diff --git a/web/hooks/use-oauth.ts b/web/hooks/use-oauth.ts index ae9c1cda66..74fbf14f0b 100644 --- a/web/hooks/use-oauth.ts +++ b/web/hooks/use-oauth.ts @@ -3,16 +3,38 @@ import { useEffect } from 'react' export const useOAuthCallback = () => { 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) { - window.opener.postMessage({ - type: 'oauth_callback', - }, '*') + if (subscriptionId) { + 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() } }, []) } -export const openOAuthPopup = (url: string, callback: () => void) => { +export const openOAuthPopup = (url: string, callback: (data?: any) => void) => { const width = 600 const height = 600 const left = window.screenX + (window.outerWidth - width) / 2 @@ -27,10 +49,20 @@ export const openOAuthPopup = (url: string, callback: () => void) => { const handleMessage = (event: MessageEvent) => { if (event.data?.type === 'oauth_callback') { window.removeEventListener('message', handleMessage) - callback() + callback(event.data) } } 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 } diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 768ce73a53..4129407367 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -730,6 +730,19 @@ const translation = { error: 'Error', configuration: 'Configuration', 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: { model: 'model', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 028d52bd2f..c5c2762206 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -1040,6 +1040,19 @@ const translation = { error: 'エラー', configuration: '構成', remove: '削除する', + or: 'または', + useOAuth: 'OAuth を使用', + useApiKey: 'API キーを使用', + authenticationFailed: '認証に失敗しました', + authenticationSuccess: '認証に成功しました', + oauthConfigFailed: 'OAuth 設定に失敗しました', + configureOAuthClient: 'OAuth クライアントを設定', + oauthClientDescription: '認証を有効にするために OAuth クライアント認証情報を設定してください', + oauthClientSaved: 'OAuth クライアント設定が正常に保存されました', + configureApiKey: 'API キーを設定', + apiKeyDescription: '認証のための API キー認証情報を設定してください', + apiKeyConfigured: 'API キーが正常に設定されました', + configurationFailed: '設定に失敗しました', }, }, tracing: { diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 50af933c04..988d99c1a7 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -1040,6 +1040,19 @@ const translation = { error: '错误', configuration: '配置', 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: {