mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 00:48:04 +08:00
feat: implement trigger plugin authentication UI (#25310)
This commit is contained in:
@ -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)
|
||||
Reference in New Issue
Block a user