diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx new file mode 100644 index 0000000000..2939924340 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx @@ -0,0 +1,323 @@ +'use client' +import { BaseForm } from '@/app/components/base/form/components/base' +import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import Modal from '@/app/components/base/modal/modal' +import Toast from '@/app/components/base/toast' +import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types' +import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers' +import { parsePluginErrorMessage } from '@/utils/error-parser' +import { useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginStore } from '../../store' +import { useSubscriptionList } from '../use-subscription-list' +import { ReadmeShowType } from '../../../readme-panel/store' +import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' + +type Props = { + onClose: () => void + subscription: TriggerSubscription + pluginDetail?: PluginDetail +} + +enum EditStep { + EditCredentials = 'edit_credentials', + EditConfiguration = 'edit_configuration', +} + +const normalizeFormType = (type: string): FormTypeEnum => { + switch (type) { + case 'string': + case 'text': + return FormTypeEnum.textInput + case 'password': + case 'secret': + return FormTypeEnum.secretInput + case 'number': + case 'integer': + return FormTypeEnum.textNumber + case 'boolean': + return FormTypeEnum.boolean + case 'select': + return FormTypeEnum.select + default: + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) + return type as FormTypeEnum + return FormTypeEnum.textInput + } +} + +const StatusStep = ({ isActive, text }: { isActive: boolean, text: string }) => { + return
+ {isActive && ( +
+ )} + {text} +
+} + +const MultiSteps = ({ currentStep }: { currentStep: EditStep }) => { + const { t } = useTranslation() + return
+ +
+ +
+} + +export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + const { refetch } = useSubscriptionList() + + const [currentStep, setCurrentStep] = useState(EditStep.EditCredentials) + + const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription() + const { mutate: verifyCredentials, isPending: isVerifying } = useVerifyTriggerSubscription() + + const parametersSchema = useMemo( + () => detail?.declaration?.trigger?.subscription_constructor?.parameters || [], + [detail?.declaration?.trigger?.subscription_constructor?.parameters], + ) + + const rawApiKeyCredentialsSchema = detail?.declaration.trigger?.subscription_constructor?.credentials_schema || [] + const apiKeyCredentialsSchema = useMemo(() => { + return rawApiKeyCredentialsSchema.map(schema => ({ + ...schema, + tooltip: schema.help, + })) + }, [rawApiKeyCredentialsSchema]) + + const basicFormRef = useRef(null) + const parametersFormRef = useRef(null) + const credentialsFormRef = useRef(null) + + const handleVerifyCredentials = () => { + const credentialsFormValues = credentialsFormRef.current?.getFormValues({ + needTransformWhenSecretFieldIsPristine: true, + }) || { values: {}, isCheckValidated: false } + + if (!credentialsFormValues.isCheckValidated) + return + + const credentials = credentialsFormValues.values + + // Clear previous errors + if (Object.keys(credentials).length > 0) { + credentialsFormRef.current?.setFields([{ + name: Object.keys(credentials)[0], + errors: [], + }]) + } + + verifyCredentials( + { + provider: subscription.provider, + subscriptionId: subscription.id, + credentials, + }, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.modal.apiKey.verify.success'), + }) + setCurrentStep(EditStep.EditConfiguration) + }, + onError: async (error: any) => { + const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.apiKey.verify.error') + if (Object.keys(credentials).length > 0) { + credentialsFormRef.current?.setFields([{ + name: Object.keys(credentials)[0], + errors: [errorMessage], + }]) + } + }, + }, + ) + } + + const handleUpdate = () => { + const basicFormValues = basicFormRef.current?.getFormValues({}) + if (!basicFormValues?.isCheckValidated) + return + + const name = basicFormValues.values.subscription_name as string + + let parameters: Record | undefined + + if (parametersSchema.length > 0) { + const paramsFormValues = parametersFormRef.current?.getFormValues({ + needTransformWhenSecretFieldIsPristine: true, + }) + if (!paramsFormValues?.isCheckValidated) + return + parameters = Object.keys(paramsFormValues.values).length > 0 ? paramsFormValues.values : undefined + } + + updateSubscription( + { + subscriptionId: subscription.id, + name, + parameters, + }, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.subscription.list.item.actions.edit.success'), + }) + refetch?.() + onClose() + }, + onError: (error: any) => { + Toast.notify({ + type: 'error', + message: error?.message || t('pluginTrigger.subscription.list.item.actions.edit.error'), + }) + }, + }, + ) + } + + const handleConfirm = () => { + if (currentStep === EditStep.EditCredentials) + handleVerifyCredentials() + else + handleUpdate() + } + + const handleCredentialsChange = () => { + if (apiKeyCredentialsSchema.length > 0) { + credentialsFormRef.current?.setFields([{ + name: apiKeyCredentialsSchema[0].name, + errors: [], + }]) + } + } + + const basicFormSchemas: FormSchema[] = useMemo(() => [ + { + name: 'subscription_name', + label: t('pluginTrigger.modal.form.subscriptionName.label'), + placeholder: t('pluginTrigger.modal.form.subscriptionName.placeholder'), + type: FormTypeEnum.textInput, + required: true, + default: subscription.name, + }, + { + name: 'callback_url', + label: t('pluginTrigger.modal.form.callbackUrl.label'), + placeholder: t('pluginTrigger.modal.form.callbackUrl.placeholder'), + type: FormTypeEnum.textInput, + required: false, + default: subscription.endpoint || '', + disabled: true, + tooltip: t('pluginTrigger.modal.form.callbackUrl.tooltip'), + showCopy: true, + }, + ], [t, subscription.name, subscription.endpoint]) + + const credentialsFormSchemas: FormSchema[] = useMemo(() => { + return apiKeyCredentialsSchema.map(schema => ({ + ...schema, + type: normalizeFormType(schema.type as string), + tooltip: schema.help, + default: subscription.credentials?.[schema.name] || schema.default, + })) + }, [apiKeyCredentialsSchema, subscription.credentials]) + + const parametersFormSchemas: FormSchema[] = useMemo(() => { + return parametersSchema.map((schema: ParametersSchema) => { + const normalizedType = normalizeFormType(schema.type as string) + return { + ...schema, + type: normalizedType, + tooltip: schema.description, + default: subscription.parameters?.[schema.name] || schema.default, + dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect + ? { + plugin_id: detail?.plugin_id || '', + provider: detail?.provider || '', + action: 'provider', + parameter: schema.name, + credential_id: subscription.id, + } + : undefined, + fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, + labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined, + } + }) + }, [parametersSchema, subscription.parameters, subscription.id, detail?.plugin_id, detail?.provider]) + + const getConfirmButtonText = () => { + if (currentStep === EditStep.EditCredentials) + return isVerifying ? t('pluginTrigger.modal.common.verifying') : t('pluginTrigger.modal.common.verify') + + return isUpdating ? t('common.operation.saving') : t('common.operation.save') + } + + return ( + : null} + > + {pluginDetail && ( + + )} + + {/* Multi-step indicator */} + + + {/* Step 1: Edit Credentials */} + {currentStep === EditStep.EditCredentials && ( +
+ {credentialsFormSchemas.length > 0 && ( + + )} +
+ )} + + {/* Step 2: Edit Configuration */} + {currentStep === EditStep.EditConfiguration && ( +
+ {/* Basic form: subscription name and callback URL */} + + + {/* Parameters */} + {parametersFormSchemas.length > 0 && ( + + )} +
+ )} +
+ ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.tsx new file mode 100644 index 0000000000..f39df561ab --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.tsx @@ -0,0 +1,28 @@ +'use client' +import type { PluginDetail } from '@/app/components/plugins/types' +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { ManualEditModal } from './manual-edit-modal' +import { OAuthEditModal } from './oauth-edit-modal' +import { ApiKeyEditModal } from './apikey-edit-modal' + +type Props = { + onClose: () => void + subscription: TriggerSubscription + pluginDetail?: PluginDetail +} + +export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => { + const credentialType = subscription.credential_type + + switch (credentialType) { + case TriggerCredentialTypeEnum.Unauthorized: + return + case TriggerCredentialTypeEnum.Oauth2: + return + case TriggerCredentialTypeEnum.ApiKey: + return + default: + return null + } +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx similarity index 88% rename from web/app/components/plugins/plugin-detail-panel/subscription-list/edit-modal.tsx rename to web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx index 8602fc0edf..496e6622f1 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx @@ -10,9 +10,9 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { useUpdateTriggerSubscription } from '@/service/use-triggers' import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { usePluginStore } from '../store' -import { useSubscriptionList } from './use-subscription-list' -import { ReadmeShowType } from '../../readme-panel/store' +import { usePluginStore } from '../../store' +import { useSubscriptionList } from '../use-subscription-list' +import { ReadmeShowType } from '../../../readme-panel/store' type Props = { onClose: () => void @@ -20,7 +20,6 @@ type Props = { pluginDetail?: PluginDetail } -// Normalize backend type to FormTypeEnum const normalizeFormType = (type: string): FormTypeEnum => { switch (type) { case 'string': @@ -43,7 +42,7 @@ const normalizeFormType = (type: string): FormTypeEnum => { } } -export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => { +export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props) => { const { t } = useTranslation() const detail = usePluginStore(state => state.detail) const { refetch } = useSubscriptionList() @@ -54,12 +53,10 @@ export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => { () => detail?.declaration?.trigger?.subscription_schema || [], [detail?.declaration?.trigger?.subscription_schema], ) + const formRef = useRef(null) const handleConfirm = () => { - // Use needTransformWhenSecretFieldIsPristine to handle secret fields - // When secret field is not modified, it will be transformed to '[__HIDDEN__]' - // Backend will preserve original value when receiving '[__HIDDEN__]' const formValues = formRef.current?.getFormValues({ needTransformWhenSecretFieldIsPristine: true, }) @@ -68,7 +65,7 @@ export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => { const name = formValues.values.subscription_name as string - // Extract properties values (exclude subscription_name and callback_url) + // Extract properties (exclude subscription_name and callback_url) const properties = { ...formValues.values } delete properties.subscription_name delete properties.callback_url diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx new file mode 100644 index 0000000000..d386acafec --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx @@ -0,0 +1,162 @@ +'use client' +import { BaseForm } from '@/app/components/base/form/components/base' +import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import Modal from '@/app/components/base/modal/modal' +import Toast from '@/app/components/base/toast' +import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types' +import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { useUpdateTriggerSubscription } from '@/service/use-triggers' +import { useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginStore } from '../../store' +import { useSubscriptionList } from '../use-subscription-list' +import { ReadmeShowType } from '../../../readme-panel/store' + +type Props = { + onClose: () => void + subscription: TriggerSubscription + pluginDetail?: PluginDetail +} + +const normalizeFormType = (type: string): FormTypeEnum => { + switch (type) { + case 'string': + case 'text': + return FormTypeEnum.textInput + case 'password': + case 'secret': + return FormTypeEnum.secretInput + case 'number': + case 'integer': + return FormTypeEnum.textNumber + case 'boolean': + return FormTypeEnum.boolean + case 'select': + return FormTypeEnum.select + default: + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) + return type as FormTypeEnum + return FormTypeEnum.textInput + } +} + +export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + const { refetch } = useSubscriptionList() + + const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription() + + const parametersSchema = useMemo( + () => detail?.declaration?.trigger?.subscription_constructor?.parameters || [], + [detail?.declaration?.trigger?.subscription_constructor?.parameters], + ) + + const formRef = useRef(null) + + const handleConfirm = () => { + const formValues = formRef.current?.getFormValues({ + needTransformWhenSecretFieldIsPristine: true, + }) + if (!formValues?.isCheckValidated) + return + + const name = formValues.values.subscription_name as string + + // Extract parameters (exclude subscription_name and callback_url) + const parameters = { ...formValues.values } + delete parameters.subscription_name + delete parameters.callback_url + + updateSubscription( + { + subscriptionId: subscription.id, + name, + parameters: Object.keys(parameters).length > 0 ? parameters : undefined, + }, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.subscription.list.item.actions.edit.success'), + }) + refetch?.() + onClose() + }, + onError: (error: any) => { + Toast.notify({ + type: 'error', + message: error?.message || t('pluginTrigger.subscription.list.item.actions.edit.error'), + }) + }, + }, + ) + } + + const formSchemas: FormSchema[] = useMemo(() => [ + { + name: 'subscription_name', + label: t('pluginTrigger.modal.form.subscriptionName.label'), + placeholder: t('pluginTrigger.modal.form.subscriptionName.placeholder'), + type: FormTypeEnum.textInput, + required: true, + default: subscription.name, + }, + { + name: 'callback_url', + label: t('pluginTrigger.modal.form.callbackUrl.label'), + placeholder: t('pluginTrigger.modal.form.callbackUrl.placeholder'), + type: FormTypeEnum.textInput, + required: false, + default: subscription.endpoint || '', + disabled: true, + tooltip: t('pluginTrigger.modal.form.callbackUrl.tooltip'), + showCopy: true, + }, + ...parametersSchema.map((schema: ParametersSchema) => { + const normalizedType = normalizeFormType(schema.type as string) + return { + ...schema, + type: normalizedType, + tooltip: schema.description, + default: subscription.parameters?.[schema.name] || schema.default, + dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect + ? { + plugin_id: detail?.plugin_id || '', + provider: detail?.provider || '', + action: 'provider', + parameter: schema.name, + credential_id: subscription.id, + } + : undefined, + fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, + labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined, + } + }), + ], [t, subscription.name, subscription.endpoint, subscription.parameters, subscription.id, parametersSchema, detail?.plugin_id, detail?.provider]) + + return ( + + {pluginDetail && ( + + )} + + + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx index 7e5641eae4..b1b5c8338b 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx @@ -12,7 +12,7 @@ import { import { useBoolean } from 'ahooks' import { useTranslation } from 'react-i18next' import { DeleteConfirm } from './delete-confirm' -import { EditModal } from './edit-modal' +import { EditModal } from './edit' type Props = { data: TriggerSubscription diff --git a/web/service/use-triggers.ts b/web/service/use-triggers.ts index b42f51ffef..1f755f953d 100644 --- a/web/service/use-triggers.ts +++ b/web/service/use-triggers.ts @@ -180,6 +180,24 @@ export const useVerifyAndUpdateTriggerSubscriptionBuilder = () => { }) } +export const useVerifyTriggerSubscription = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'verify-subscription'], + mutationFn: (payload: { + provider: string; + subscriptionId: string; + credentials?: Record; + }) => { + const { provider, subscriptionId, ...body } = payload + return post<{ verified: boolean }>( + `/workspaces/current/trigger-provider/${provider}/subscriptions/verify/${subscriptionId}`, + { body }, + { silent: true }, + ) + }, + }) +} + export type BuildTriggerSubscriptionPayload = { provider: string subscriptionBuilderId: string @@ -215,6 +233,7 @@ export type UpdateTriggerSubscriptionPayload = { subscriptionId: string name?: string properties?: Record + parameters?: Record } export const useUpdateTriggerSubscription = () => {