feat: add editing support for trigger subscriptions (#29957)

Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Maries 2025-12-24 19:15:54 +08:00 committed by GitHub
parent 5896bc89f5
commit 02e0fadef7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1465 additions and 156 deletions

View File

@ -1,5 +1,6 @@
import io import io
from typing import Literal from collections.abc import Mapping
from typing import Any, Literal
from flask import request, send_file from flask import request, send_file
from flask_restx import Resource from flask_restx import Resource
@ -141,6 +142,15 @@ class ParserDynamicOptions(BaseModel):
provider_type: Literal["tool", "trigger"] provider_type: Literal["tool", "trigger"]
class ParserDynamicOptionsWithCredentials(BaseModel):
plugin_id: str
provider: str
action: str
parameter: str
credential_id: str
credentials: Mapping[str, Any]
class PluginPermissionSettingsPayload(BaseModel): class PluginPermissionSettingsPayload(BaseModel):
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
@ -183,6 +193,7 @@ reg(ParserGithubUpgrade)
reg(ParserUninstall) reg(ParserUninstall)
reg(ParserPermissionChange) reg(ParserPermissionChange)
reg(ParserDynamicOptions) reg(ParserDynamicOptions)
reg(ParserDynamicOptionsWithCredentials)
reg(ParserPreferencesChange) reg(ParserPreferencesChange)
reg(ParserExcludePlugin) reg(ParserExcludePlugin)
reg(ParserReadme) reg(ParserReadme)
@ -657,6 +668,37 @@ class PluginFetchDynamicSelectOptionsApi(Resource):
return jsonable_encoder({"options": options}) return jsonable_encoder({"options": options})
@console_ns.route("/workspaces/current/plugin/parameters/dynamic-options-with-credentials")
class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
@console_ns.expect(console_ns.models[ParserDynamicOptionsWithCredentials.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
def post(self):
"""Fetch dynamic options using credentials directly (for edit mode)."""
current_user, tenant_id = current_account_with_tenant()
user_id = current_user.id
args = ParserDynamicOptionsWithCredentials.model_validate(console_ns.payload)
try:
options = PluginParameterService.get_dynamic_select_options_with_credentials(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=args.plugin_id,
provider=args.provider,
action=args.action,
parameter=args.parameter,
credential_id=args.credential_id,
credentials=args.credentials,
)
except PluginDaemonClientSideError as e:
raise ValueError(e)
return jsonable_encoder({"options": options})
@console_ns.route("/workspaces/current/plugin/preferences/change") @console_ns.route("/workspaces/current/plugin/preferences/change")
class PluginChangePreferencesApi(Resource): class PluginChangePreferencesApi(Resource):
@console_ns.expect(console_ns.models[ParserPreferencesChange.__name__]) @console_ns.expect(console_ns.models[ParserPreferencesChange.__name__])

View File

@ -1,11 +1,15 @@
import logging import logging
from collections.abc import Mapping
from typing import Any
from flask import make_response, redirect, request from flask import make_response, redirect, request
from flask_restx import Resource, reqparse from flask_restx import Resource, reqparse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden from werkzeug.exceptions import BadRequest, Forbidden
from configs import dify_config from configs import dify_config
from constants import HIDDEN_VALUE, UNKNOWN_VALUE
from controllers.web.error import NotFoundError from controllers.web.error import NotFoundError
from core.model_runtime.utils.encoders import jsonable_encoder from core.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.entities.plugin_daemon import CredentialType
@ -32,6 +36,32 @@ from ..wraps import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TriggerSubscriptionUpdateRequest(BaseModel):
"""Request payload for updating a trigger subscription"""
name: str | None = Field(default=None, description="The name for the subscription")
credentials: Mapping[str, Any] | None = Field(default=None, description="The credentials for the subscription")
parameters: Mapping[str, Any] | None = Field(default=None, description="The parameters for the subscription")
properties: Mapping[str, Any] | None = Field(default=None, description="The properties for the subscription")
class TriggerSubscriptionVerifyRequest(BaseModel):
"""Request payload for verifying subscription credentials."""
credentials: Mapping[str, Any] = Field(description="The credentials to verify")
console_ns.schema_model(
TriggerSubscriptionUpdateRequest.__name__,
TriggerSubscriptionUpdateRequest.model_json_schema(ref_template="#/definitions/{model}"),
)
console_ns.schema_model(
TriggerSubscriptionVerifyRequest.__name__,
TriggerSubscriptionVerifyRequest.model_json_schema(ref_template="#/definitions/{model}"),
)
@console_ns.route("/workspaces/current/trigger-provider/<path:provider>/icon") @console_ns.route("/workspaces/current/trigger-provider/<path:provider>/icon")
class TriggerProviderIconApi(Resource): class TriggerProviderIconApi(Resource):
@setup_required @setup_required
@ -155,16 +185,16 @@ parser_api = (
@console_ns.route( @console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/verify/<path:subscription_builder_id>", "/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/verify-and-update/<path:subscription_builder_id>",
) )
class TriggerSubscriptionBuilderVerifyApi(Resource): class TriggerSubscriptionBuilderVerifyAndUpdateApi(Resource):
@console_ns.expect(parser_api) @console_ns.expect(parser_api)
@setup_required @setup_required
@login_required @login_required
@edit_permission_required @edit_permission_required
@account_initialization_required @account_initialization_required
def post(self, provider, subscription_builder_id): def post(self, provider, subscription_builder_id):
"""Verify a subscription instance for a trigger provider""" """Verify and update a subscription instance for a trigger provider"""
user = current_user user = current_user
assert user.current_tenant_id is not None assert user.current_tenant_id is not None
@ -289,6 +319,83 @@ class TriggerSubscriptionBuilderBuildApi(Resource):
raise ValueError(str(e)) from e raise ValueError(str(e)) from e
@console_ns.route(
"/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/update",
)
class TriggerSubscriptionUpdateApi(Resource):
@console_ns.expect(console_ns.models[TriggerSubscriptionUpdateRequest.__name__])
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
def post(self, subscription_id: str):
"""Update a subscription instance"""
user = current_user
assert user.current_tenant_id is not None
args = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload)
subscription = TriggerProviderService.get_subscription_by_id(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
)
if not subscription:
raise NotFoundError(f"Subscription {subscription_id} not found")
provider_id = TriggerProviderID(subscription.provider_id)
try:
# rename only
if (
args.name is not None
and args.credentials is None
and args.parameters is None
and args.properties is None
):
TriggerProviderService.update_trigger_subscription(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
name=args.name,
)
return 200
# rebuild for create automatically by the provider
match subscription.credential_type:
case CredentialType.UNAUTHORIZED:
TriggerProviderService.update_trigger_subscription(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
name=args.name,
properties=args.properties,
)
return 200
case CredentialType.API_KEY | CredentialType.OAUTH2:
if args.credentials:
new_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else subscription.credentials.get(key, UNKNOWN_VALUE)
for key, value in args.credentials.items()
}
else:
new_credentials = subscription.credentials
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=user.current_tenant_id,
name=args.name,
provider_id=provider_id,
subscription_id=subscription_id,
credentials=new_credentials,
parameters=args.parameters or subscription.parameters,
)
return 200
case _:
raise BadRequest("Invalid credential type")
except ValueError as e:
raise BadRequest(str(e))
except Exception as e:
logger.exception("Error updating subscription", exc_info=e)
raise
@console_ns.route( @console_ns.route(
"/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/delete", "/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/delete",
) )
@ -576,3 +683,38 @@ class TriggerOAuthClientManageApi(Resource):
except Exception as e: except Exception as e:
logger.exception("Error removing OAuth client", exc_info=e) logger.exception("Error removing OAuth client", exc_info=e)
raise raise
@console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/verify/<path:subscription_id>",
)
class TriggerSubscriptionVerifyApi(Resource):
@console_ns.expect(console_ns.models[TriggerSubscriptionVerifyRequest.__name__])
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
def post(self, provider, subscription_id):
"""Verify credentials for an existing subscription (edit mode only)"""
user = current_user
assert user.current_tenant_id is not None
verify_request: TriggerSubscriptionVerifyRequest = TriggerSubscriptionVerifyRequest.model_validate(
console_ns.payload
)
try:
result = TriggerProviderService.verify_subscription_credentials(
tenant_id=user.current_tenant_id,
user_id=user.id,
provider_id=TriggerProviderID(provider),
subscription_id=subscription_id,
credentials=verify_request.credentials,
)
return result
except ValueError as e:
logger.warning("Credential verification failed", exc_info=e)
raise BadRequest(str(e)) from e
except Exception as e:
logger.exception("Error verifying subscription credentials", exc_info=e)
raise BadRequest(str(e)) from e

View File

@ -67,12 +67,16 @@ def create_trigger_provider_encrypter_for_subscription(
def delete_cache_for_subscription(tenant_id: str, provider_id: str, subscription_id: str): def delete_cache_for_subscription(tenant_id: str, provider_id: str, subscription_id: str):
cache = TriggerProviderCredentialsCache( TriggerProviderCredentialsCache(
tenant_id=tenant_id, tenant_id=tenant_id,
provider_id=provider_id, provider_id=provider_id,
credential_id=subscription_id, credential_id=subscription_id,
) ).delete()
cache.delete() TriggerProviderPropertiesCache(
tenant_id=tenant_id,
provider_id=provider_id,
subscription_id=subscription_id,
).delete()
def create_trigger_provider_encrypter_for_properties( def create_trigger_provider_encrypter_for_properties(

View File

@ -105,3 +105,49 @@ class PluginParameterService:
) )
.options .options
) )
@staticmethod
def get_dynamic_select_options_with_credentials(
tenant_id: str,
user_id: str,
plugin_id: str,
provider: str,
action: str,
parameter: str,
credential_id: str,
credentials: Mapping[str, Any],
) -> Sequence[PluginParameterOption]:
"""
Get dynamic select options using provided credentials directly.
Used for edit mode when credentials have been modified but not yet saved.
Security: credential_id is validated against tenant_id to ensure
users can only access their own credentials.
"""
from constants import HIDDEN_VALUE
# Get original subscription to replace hidden values (with tenant_id check for security)
original_subscription = TriggerProviderService.get_subscription_by_id(tenant_id, credential_id)
if not original_subscription:
raise ValueError(f"Subscription {credential_id} not found")
# Replace [__HIDDEN__] with original values
resolved_credentials: dict[str, Any] = {
key: (original_subscription.credentials.get(key) if value == HIDDEN_VALUE else value)
for key, value in credentials.items()
}
return (
DynamicSelectClient()
.fetch_dynamic_select_options(
tenant_id,
user_id,
plugin_id,
provider,
action,
resolved_credentials,
CredentialType.API_KEY.value,
parameter,
)
.options
)

View File

@ -94,16 +94,23 @@ class TriggerProviderService:
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
for subscription in subscriptions: for subscription in subscriptions:
encrypter, _ = create_trigger_provider_encrypter_for_subscription( credential_encrypter, _ = create_trigger_provider_encrypter_for_subscription(
tenant_id=tenant_id, tenant_id=tenant_id,
controller=provider_controller, controller=provider_controller,
subscription=subscription, subscription=subscription,
) )
subscription.credentials = dict( subscription.credentials = dict(
encrypter.mask_credentials(dict(encrypter.decrypt(subscription.credentials))) credential_encrypter.mask_credentials(dict(credential_encrypter.decrypt(subscription.credentials)))
) )
subscription.properties = dict(encrypter.mask_credentials(dict(encrypter.decrypt(subscription.properties)))) properties_encrypter, _ = create_trigger_provider_encrypter_for_properties(
subscription.parameters = dict(encrypter.mask_credentials(dict(encrypter.decrypt(subscription.parameters)))) tenant_id=tenant_id,
controller=provider_controller,
subscription=subscription,
)
subscription.properties = dict(
properties_encrypter.mask_credentials(dict(properties_encrypter.decrypt(subscription.properties)))
)
subscription.parameters = dict(subscription.parameters)
count = workflows_in_use_map.get(subscription.id) count = workflows_in_use_map.get(subscription.id)
subscription.workflows_in_use = count if count is not None else 0 subscription.workflows_in_use = count if count is not None else 0
@ -209,6 +216,101 @@ class TriggerProviderService:
logger.exception("Failed to add trigger provider") logger.exception("Failed to add trigger provider")
raise ValueError(str(e)) raise ValueError(str(e))
@classmethod
def update_trigger_subscription(
cls,
tenant_id: str,
subscription_id: str,
name: str | None = None,
properties: Mapping[str, Any] | None = None,
parameters: Mapping[str, Any] | None = None,
credentials: Mapping[str, Any] | None = None,
credential_expires_at: int | None = None,
expires_at: int | None = None,
) -> None:
"""
Update an existing trigger subscription.
:param tenant_id: Tenant ID
:param subscription_id: Subscription instance ID
:param name: Optional new name for this subscription
:param properties: Optional new properties
:param parameters: Optional new parameters
:param credentials: Optional new credentials
:param credential_expires_at: Optional new credential expiration timestamp
:param expires_at: Optional new expiration timestamp
:return: Success response with updated subscription info
"""
with Session(db.engine, expire_on_commit=False) as session:
# Use distributed lock to prevent race conditions on the same subscription
lock_key = f"trigger_subscription_update_lock:{tenant_id}_{subscription_id}"
with redis_client.lock(lock_key, timeout=20):
subscription: TriggerSubscription | None = (
session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first()
)
if not subscription:
raise ValueError(f"Trigger subscription {subscription_id} not found")
provider_id = TriggerProviderID(subscription.provider_id)
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
# Check for name uniqueness if name is being updated
if name is not None and name != subscription.name:
existing = (
session.query(TriggerSubscription)
.filter_by(tenant_id=tenant_id, provider_id=str(provider_id), name=name)
.first()
)
if existing:
raise ValueError(f"Subscription name '{name}' already exists for this provider")
subscription.name = name
# Update properties if provided
if properties is not None:
properties_encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=provider_controller.get_properties_schema(),
cache=NoOpProviderCredentialCache(),
)
# Handle hidden values - preserve original encrypted values
original_properties = properties_encrypter.decrypt(subscription.properties)
new_properties: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else original_properties.get(key, UNKNOWN_VALUE)
for key, value in properties.items()
}
subscription.properties = dict(properties_encrypter.encrypt(new_properties))
# Update parameters if provided
if parameters is not None:
subscription.parameters = dict(parameters)
# Update credentials if provided
if credentials is not None:
credential_type = CredentialType.of(subscription.credential_type)
credential_encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=provider_controller.get_credential_schema_config(credential_type),
cache=NoOpProviderCredentialCache(),
)
subscription.credentials = dict(credential_encrypter.encrypt(dict(credentials)))
# Update credential expiration timestamp if provided
if credential_expires_at is not None:
subscription.credential_expires_at = credential_expires_at
# Update expiration timestamp if provided
if expires_at is not None:
subscription.expires_at = expires_at
session.commit()
# Clear subscription cache
delete_cache_for_subscription(
tenant_id=tenant_id,
provider_id=subscription.provider_id,
subscription_id=subscription.id,
)
@classmethod @classmethod
def get_subscription_by_id(cls, tenant_id: str, subscription_id: str | None = None) -> TriggerSubscription | None: def get_subscription_by_id(cls, tenant_id: str, subscription_id: str | None = None) -> TriggerSubscription | None:
""" """
@ -258,7 +360,9 @@ class TriggerProviderService:
credential_type: CredentialType = CredentialType.of(subscription.credential_type) credential_type: CredentialType = CredentialType.of(subscription.credential_type)
is_auto_created: bool = credential_type in [CredentialType.OAUTH2, CredentialType.API_KEY] is_auto_created: bool = credential_type in [CredentialType.OAUTH2, CredentialType.API_KEY]
if is_auto_created: if not is_auto_created:
return None
provider_id = TriggerProviderID(subscription.provider_id) provider_id = TriggerProviderID(subscription.provider_id)
provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider(
tenant_id=tenant_id, provider_id=provider_id tenant_id=tenant_id, provider_id=provider_id
@ -280,8 +384,8 @@ class TriggerProviderService:
except Exception as e: except Exception as e:
logger.exception("Error unsubscribing trigger", exc_info=e) logger.exception("Error unsubscribing trigger", exc_info=e)
# Clear cache
session.delete(subscription) session.delete(subscription)
# Clear cache
delete_cache_for_subscription( delete_cache_for_subscription(
tenant_id=tenant_id, tenant_id=tenant_id,
provider_id=subscription.provider_id, provider_id=subscription.provider_id,
@ -688,3 +792,125 @@ class TriggerProviderService:
) )
subscription.properties = dict(properties_encrypter.decrypt(subscription.properties)) subscription.properties = dict(properties_encrypter.decrypt(subscription.properties))
return subscription return subscription
@classmethod
def verify_subscription_credentials(
cls,
tenant_id: str,
user_id: str,
provider_id: TriggerProviderID,
subscription_id: str,
credentials: Mapping[str, Any],
) -> dict[str, Any]:
"""
Verify credentials for an existing subscription without updating it.
This is used in edit mode to validate new credentials before rebuild.
:param tenant_id: Tenant ID
:param user_id: User ID
:param provider_id: Provider identifier
:param subscription_id: Subscription ID
:param credentials: New credentials to verify
:return: dict with 'verified' boolean
"""
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
if not provider_controller:
raise ValueError(f"Provider {provider_id} not found")
subscription = cls.get_subscription_by_id(
tenant_id=tenant_id,
subscription_id=subscription_id,
)
if not subscription:
raise ValueError(f"Subscription {subscription_id} not found")
credential_type = CredentialType.of(subscription.credential_type)
# For API Key, validate the new credentials
if credential_type == CredentialType.API_KEY:
new_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else subscription.credentials.get(key, UNKNOWN_VALUE)
for key, value in credentials.items()
}
try:
provider_controller.validate_credentials(user_id, credentials=new_credentials)
return {"verified": True}
except Exception as e:
raise ValueError(f"Invalid credentials: {e}") from e
return {"verified": True}
@classmethod
def rebuild_trigger_subscription(
cls,
tenant_id: str,
provider_id: TriggerProviderID,
subscription_id: str,
credentials: Mapping[str, Any],
parameters: Mapping[str, Any],
name: str | None = None,
) -> None:
"""
Create a subscription builder for rebuilding an existing subscription.
This method creates a builder pre-filled with data from the rebuild request,
keeping the same subscription_id and endpoint_id so the webhook URL remains unchanged.
:param tenant_id: Tenant ID
:param name: Name for the subscription
:param subscription_id: Subscription ID
:param provider_id: Provider identifier
:param credentials: Credentials for the subscription
:param parameters: Parameters for the subscription
:return: SubscriptionBuilderApiEntity
"""
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
if not provider_controller:
raise ValueError(f"Provider {provider_id} not found")
subscription = TriggerProviderService.get_subscription_by_id(
tenant_id=tenant_id,
subscription_id=subscription_id,
)
if not subscription:
raise ValueError(f"Subscription {subscription_id} not found")
credential_type = CredentialType.of(subscription.credential_type)
if credential_type not in [CredentialType.OAUTH2, CredentialType.API_KEY]:
raise ValueError("Credential type not supported for rebuild")
# TODO: Trying to invoke update api of the plugin trigger provider
# FALLBACK: If the update api is not implemented, delete the previous subscription and create a new one
# Delete the previous subscription
user_id = subscription.user_id
TriggerManager.unsubscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
subscription=subscription.to_entity(),
credentials=subscription.credentials,
credential_type=credential_type,
)
# Create a new subscription with the same subscription_id and endpoint_id
new_subscription: TriggerSubscriptionEntity = TriggerManager.subscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
endpoint=generate_plugin_trigger_endpoint_url(subscription.endpoint_id),
parameters=parameters,
credentials=credentials,
credential_type=credential_type,
)
TriggerProviderService.update_trigger_subscription(
tenant_id=tenant_id,
subscription_id=subscription.id,
name=name,
parameters=parameters,
credentials=credentials,
properties=new_subscription.properties,
expires_at=new_subscription.expires_at,
)

View File

@ -453,11 +453,12 @@ class TriggerSubscriptionBuilderService:
if not subscription_builder: if not subscription_builder:
return None return None
try:
# response to validation endpoint # response to validation endpoint
controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider(
tenant_id=subscription_builder.tenant_id, provider_id=TriggerProviderID(subscription_builder.provider_id) tenant_id=subscription_builder.tenant_id,
provider_id=TriggerProviderID(subscription_builder.provider_id),
) )
try:
dispatch_response: TriggerDispatchResponse = controller.dispatch( dispatch_response: TriggerDispatchResponse = controller.dispatch(
request=request, request=request,
subscription=subscription_builder.to_subscription(), subscription=subscription_builder.to_subscription(),

View File

@ -46,7 +46,7 @@ const PluginDetailPanel: FC<Props> = ({
name: detail.name, name: detail.name,
id: detail.id, id: detail.id,
}) })
}, [detail]) }, [detail, setDetail])
if (!detail) if (!detail)
return null return null
@ -69,7 +69,7 @@ const PluginDetailPanel: FC<Props> = ({
<div className="flex-1"> <div className="flex-1">
{detail.declaration.category === PluginCategoryEnum.trigger && ( {detail.declaration.category === PluginCategoryEnum.trigger && (
<> <>
<SubscriptionList /> <SubscriptionList pluginDetail={detail} />
<TriggerEventsList /> <TriggerEventsList />
</> </>
)} )}

View File

@ -20,7 +20,7 @@ import {
useCreateTriggerSubscriptionBuilder, useCreateTriggerSubscriptionBuilder,
useTriggerSubscriptionBuilderLogs, useTriggerSubscriptionBuilderLogs,
useUpdateTriggerSubscriptionBuilder, useUpdateTriggerSubscriptionBuilder,
useVerifyTriggerSubscriptionBuilder, useVerifyAndUpdateTriggerSubscriptionBuilder,
} from '@/service/use-triggers' } from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser' import { parsePluginErrorMessage } from '@/utils/error-parser'
import { isPrivateOrLocalAddress } from '@/utils/urlValidation' import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
@ -40,6 +40,15 @@ const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTyp
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized, [SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
} }
const MODAL_TITLE_KEY_MAP: Record<
SupportedCreationMethods,
'pluginTrigger.modal.apiKey.title' | 'pluginTrigger.modal.oauth.title' | 'pluginTrigger.modal.manual.title'
> = {
[SupportedCreationMethods.APIKEY]: 'pluginTrigger.modal.apiKey.title',
[SupportedCreationMethods.OAUTH]: 'pluginTrigger.modal.oauth.title',
[SupportedCreationMethods.MANUAL]: 'pluginTrigger.modal.manual.title',
}
enum ApiKeyStep { enum ApiKeyStep {
Verify = 'verify', Verify = 'verify',
Configuration = 'configuration', Configuration = 'configuration',
@ -104,7 +113,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder) const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
const isInitializedRef = useRef(false) const isInitializedRef = useRef(false)
const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyTriggerSubscriptionBuilder() const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder()
const { mutateAsync: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder() const { mutateAsync: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder()
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription() const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder() const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder()
@ -117,13 +126,13 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] // apikey and oauth const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] // apikey and oauth
const autoCommonParametersFormRef = React.useRef<FormRefObject>(null) const autoCommonParametersFormRef = React.useRef<FormRefObject>(null)
const rawApiKeyCredentialsSchema = detail?.declaration.trigger?.subscription_constructor?.credentials_schema || []
const apiKeyCredentialsSchema = useMemo(() => { const apiKeyCredentialsSchema = useMemo(() => {
return rawApiKeyCredentialsSchema.map(schema => ({ const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || []
return rawSchema.map(schema => ({
...schema, ...schema,
tooltip: schema.help, tooltip: schema.help,
})) }))
}, [rawApiKeyCredentialsSchema]) }, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema])
const apiKeyCredentialsFormRef = React.useRef<FormRefObject>(null) const apiKeyCredentialsFormRef = React.useRef<FormRefObject>(null)
const { data: logData } = useTriggerSubscriptionBuilderLogs( const { data: logData } = useTriggerSubscriptionBuilderLogs(
@ -163,7 +172,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
if (form) if (form)
form.setFieldValue('callback_url', subscriptionBuilder.endpoint) form.setFieldValue('callback_url', subscriptionBuilder.endpoint)
if (isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) { if (isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) {
console.log('isPrivateOrLocalAddress', isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) console.warn('callback_url is private or local address', subscriptionBuilder.endpoint)
subscriptionFormRef.current?.setFields([{ subscriptionFormRef.current?.setFields([{
name: 'callback_url', name: 'callback_url',
warnings: [t('pluginTrigger.modal.form.callbackUrl.privateAddressWarning')], warnings: [t('pluginTrigger.modal.form.callbackUrl.privateAddressWarning')],
@ -179,7 +188,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
}, [subscriptionBuilder?.endpoint, currentStep, t]) }, [subscriptionBuilder?.endpoint, currentStep, t])
const debouncedUpdate = useMemo( const debouncedUpdate = useMemo(
() => debounce((provider: string, builderId: string, properties: Record<string, any>) => { () => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => {
updateBuilder( updateBuilder(
{ {
provider, provider,
@ -187,11 +196,12 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
properties, properties,
}, },
{ {
onError: (error: any) => { onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.errors.updateFailed')
console.error('Failed to update subscription builder:', error) console.error('Failed to update subscription builder:', error)
Toast.notify({ Toast.notify({
type: 'error', type: 'error',
message: error?.message || t('pluginTrigger.modal.errors.updateFailed'), message: errorMessage,
}) })
}, },
}, },
@ -246,7 +256,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
}) })
setCurrentStep(ApiKeyStep.Configuration) setCurrentStep(ApiKeyStep.Configuration)
}, },
onError: async (error: any) => { onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.apiKey.verify.error') const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.apiKey.verify.error')
apiKeyCredentialsFormRef.current?.setFields([{ apiKeyCredentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0], name: Object.keys(credentials)[0],
@ -303,7 +313,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
onClose() onClose()
refetch?.() refetch?.()
}, },
onError: async (error: any) => { onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.subscription.createFailed') const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.subscription.createFailed')
Toast.notify({ Toast.notify({
type: 'error', type: 'error',
@ -328,14 +338,17 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
}]) }])
} }
const confirmButtonText = useMemo(() => {
if (currentStep === ApiKeyStep.Verify)
return isVerifyingCredentials ? t('pluginTrigger.modal.common.verifying') : t('pluginTrigger.modal.common.verify')
return isBuilding ? t('pluginTrigger.modal.common.creating') : t('pluginTrigger.modal.common.create')
}, [currentStep, isVerifyingCredentials, isBuilding, t])
return ( return (
<Modal <Modal
title={t(`pluginTrigger.modal.${createType === SupportedCreationMethods.APIKEY ? 'apiKey' : createType.toLowerCase()}.title` as any)} title={t(MODAL_TITLE_KEY_MAP[createType])}
confirmButtonText={ confirmButtonText={confirmButtonText}
currentStep === ApiKeyStep.Verify
? isVerifyingCredentials ? t('pluginTrigger.modal.common.verifying') : t('pluginTrigger.modal.common.verify')
: isBuilding ? t('pluginTrigger.modal.common.creating') : t('pluginTrigger.modal.common.create')
}
onClose={onClose} onClose={onClose}
onCancel={onClose} onCancel={onClose}
onConfirm={handleConfirm} onConfirm={handleConfirm}

View File

@ -19,7 +19,7 @@ import {
useConfigureTriggerOAuth, useConfigureTriggerOAuth,
useDeleteTriggerOAuth, useDeleteTriggerOAuth,
useInitiateTriggerOAuth, useInitiateTriggerOAuth,
useVerifyTriggerSubscriptionBuilder, useVerifyAndUpdateTriggerSubscriptionBuilder,
} from '@/service/use-triggers' } from '@/service/use-triggers'
import { usePluginStore } from '../../store' import { usePluginStore } from '../../store'
@ -65,10 +65,29 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
const providerName = detail?.provider || '' const providerName = detail?.provider || ''
const { mutate: initiateOAuth } = useInitiateTriggerOAuth() const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
const { mutate: verifyBuilder } = useVerifyTriggerSubscriptionBuilder() const { mutate: verifyBuilder } = useVerifyAndUpdateTriggerSubscriptionBuilder()
const { mutate: configureOAuth } = useConfigureTriggerOAuth() const { mutate: configureOAuth } = useConfigureTriggerOAuth()
const { mutate: deleteOAuth } = useDeleteTriggerOAuth() const { mutate: deleteOAuth } = useDeleteTriggerOAuth()
const confirmButtonText = useMemo(() => {
if (authorizationStatus === AuthorizationStatusEnum.Pending)
return t('pluginTrigger.modal.common.authorizing')
if (authorizationStatus === AuthorizationStatusEnum.Success)
return t('pluginTrigger.modal.oauth.authorization.waitingJump')
return t('plugin.auth.saveAndAuth')
}, [authorizationStatus, t])
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message)
return error.message
if (typeof error === 'object' && error && 'message' in error) {
const message = (error as { message?: string }).message
if (typeof message === 'string' && message)
return message
}
return fallback
}
const handleAuthorization = () => { const handleAuthorization = () => {
setAuthorizationStatus(AuthorizationStatusEnum.Pending) setAuthorizationStatus(AuthorizationStatusEnum.Pending)
initiateOAuth(providerName, { initiateOAuth(providerName, {
@ -130,10 +149,10 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
message: t('pluginTrigger.modal.oauth.remove.success'), message: t('pluginTrigger.modal.oauth.remove.success'),
}) })
}, },
onError: (error: any) => { onError: (error: unknown) => {
Toast.notify({ Toast.notify({
type: 'error', type: 'error',
message: error?.message || t('pluginTrigger.modal.oauth.remove.failed'), message: getErrorMessage(error, t('pluginTrigger.modal.oauth.remove.failed')),
}) })
}, },
}) })
@ -179,9 +198,7 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
return ( return (
<Modal <Modal
title={t('pluginTrigger.modal.oauth.title')} title={t('pluginTrigger.modal.oauth.title')}
confirmButtonText={authorizationStatus === AuthorizationStatusEnum.Pending confirmButtonText={confirmButtonText}
? t('pluginTrigger.modal.common.authorizing')
: authorizationStatus === AuthorizationStatusEnum.Success ? t('pluginTrigger.modal.oauth.authorization.waitingJump') : t('plugin.auth.saveAndAuth')}
cancelButtonText={t('plugin.auth.saveOnly')} cancelButtonText={t('plugin.auth.saveOnly')}
extraButtonText={t('common.operation.cancel')} extraButtonText={t('common.operation.cancel')}
showExtraButton showExtraButton

View File

@ -0,0 +1,349 @@
'use client'
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { isEqual } from 'lodash-es'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal/modal'
import Toast from '@/app/components/base/toast'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
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 HIDDEN_SECRET_VALUE = '[__HIDDEN__]'
// Check if all credential values are hidden (meaning nothing was changed)
const areAllCredentialsHidden = (credentials: Record<string, unknown>): boolean => {
return Object.values(credentials).every(value => value === HIDDEN_SECRET_VALUE)
}
const StatusStep = ({ isActive, text, onClick, clickable }: {
isActive: boolean
text: string
onClick?: () => void
clickable?: boolean
}) => {
return (
<div
className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive
? 'text-state-accent-solid'
: 'text-text-tertiary'} ${clickable ? 'cursor-pointer hover:text-text-secondary' : ''}`}
onClick={clickable ? onClick : undefined}
>
{isActive && (
<div className="h-1 w-1 rounded-full bg-state-accent-solid"></div>
)}
{text}
</div>
)
}
const MultiSteps = ({ currentStep, onStepClick }: { currentStep: EditStep, onStepClick?: (step: EditStep) => void }) => {
const { t } = useTranslation()
return (
<div className="mb-6 flex w-1/3 items-center gap-2">
<StatusStep
isActive={currentStep === EditStep.EditCredentials}
text={t('pluginTrigger.modal.steps.verify')}
onClick={() => onStepClick?.(EditStep.EditCredentials)}
clickable={currentStep === EditStep.EditConfiguration}
/>
<div className="h-px w-3 shrink-0 bg-divider-deep"></div>
<StatusStep
isActive={currentStep === EditStep.EditConfiguration}
text={t('pluginTrigger.modal.steps.configuration')}
/>
</div>
)
}
export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const { refetch } = useSubscriptionList()
const [currentStep, setCurrentStep] = useState<EditStep>(EditStep.EditCredentials)
const [verifiedCredentials, setVerifiedCredentials] = useState<Record<string, unknown> | null>(null)
const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription()
const { mutate: verifyCredentials, isPending: isVerifying } = useVerifyTriggerSubscription()
const parametersSchema = useMemo<ParametersSchema[]>(
() => detail?.declaration?.trigger?.subscription_constructor?.parameters || [],
[detail?.declaration?.trigger?.subscription_constructor?.parameters],
)
const apiKeyCredentialsSchema = useMemo(() => {
const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || []
return rawSchema.map(schema => ({
...schema,
tooltip: schema.help,
}))
}, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema])
const basicFormRef = useRef<FormRefObject>(null)
const parametersFormRef = useRef<FormRefObject>(null)
const credentialsFormRef = useRef<FormRefObject>(null)
const handleVerifyCredentials = () => {
const credentialsFormValues = credentialsFormRef.current?.getFormValues({
needTransformWhenSecretFieldIsPristine: true,
}) || { values: {}, isCheckValidated: false }
if (!credentialsFormValues.isCheckValidated)
return
const credentials = credentialsFormValues.values
verifyCredentials(
{
provider: subscription.provider,
subscriptionId: subscription.id,
credentials,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('pluginTrigger.modal.apiKey.verify.success'),
})
// Only save credentials if any field was modified (not all hidden)
setVerifiedCredentials(areAllCredentialsHidden(credentials) ? null : credentials)
setCurrentStep(EditStep.EditConfiguration)
},
onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.apiKey.verify.error')
Toast.notify({
type: 'error',
message: errorMessage,
})
},
},
)
}
const handleUpdate = () => {
const basicFormValues = basicFormRef.current?.getFormValues({})
if (!basicFormValues?.isCheckValidated)
return
const name = basicFormValues.values.subscription_name as string
let parameters: Record<string, unknown> | undefined
if (parametersSchema.length > 0) {
const paramsFormValues = parametersFormRef.current?.getFormValues({
needTransformWhenSecretFieldIsPristine: true,
})
if (!paramsFormValues?.isCheckValidated)
return
// Only send parameters if changed
const hasChanged = !isEqual(paramsFormValues.values, subscription.parameters || {})
parameters = hasChanged ? paramsFormValues.values : undefined
}
updateSubscription(
{
subscriptionId: subscription.id,
name,
parameters,
credentials: verifiedCredentials || undefined,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('pluginTrigger.subscription.list.item.actions.edit.success'),
})
refetch?.()
onClose()
},
onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.subscription.list.item.actions.edit.error')
Toast.notify({
type: 'error',
message: errorMessage,
})
},
},
)
}
const handleConfirm = () => {
if (currentStep === EditStep.EditCredentials)
handleVerifyCredentials()
else
handleUpdate()
}
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,
credentials: verifiedCredentials || undefined,
}
: 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, verifiedCredentials])
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')
}
const handleBack = () => {
setCurrentStep(EditStep.EditCredentials)
setVerifiedCredentials(null)
}
return (
<Modal
title={t('pluginTrigger.subscription.list.item.actions.edit.title')}
confirmButtonText={getConfirmButtonText()}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isUpdating || isVerifying}
showExtraButton={currentStep === EditStep.EditConfiguration}
extraButtonText={t('pluginTrigger.modal.common.back')}
extraButtonVariant="secondary"
onExtraButtonClick={handleBack}
clickOutsideNotClose
wrapperClassName="!z-[101]"
bottomSlot={currentStep === EditStep.EditCredentials ? <EncryptedBottom /> : null}
>
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
)}
{/* Multi-step indicator */}
<MultiSteps currentStep={currentStep} onStepClick={handleBack} />
{/* Step 1: Edit Credentials */}
{currentStep === EditStep.EditCredentials && (
<div className="mb-4">
{credentialsFormSchemas.length > 0 && (
<BaseForm
formSchemas={credentialsFormSchemas}
ref={credentialsFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
preventDefaultSubmit={true}
/>
)}
</div>
)}
{/* Step 2: Edit Configuration */}
{currentStep === EditStep.EditConfiguration && (
<div className="max-h-[70vh]">
{/* Basic form: subscription name and callback URL */}
<BaseForm
formSchemas={basicFormSchemas}
ref={basicFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4 mb-4"
/>
{/* Parameters */}
{parametersFormSchemas.length > 0 && (
<BaseForm
formSchemas={parametersFormSchemas}
ref={parametersFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
)}
</div>
)}
</Modal>
)
}

View File

@ -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 { ApiKeyEditModal } from './apikey-edit-modal'
import { ManualEditModal } from './manual-edit-modal'
import { OAuthEditModal } from './oauth-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 <ManualEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} />
case TriggerCredentialTypeEnum.Oauth2:
return <OAuthEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} />
case TriggerCredentialTypeEnum.ApiKey:
return <ApiKeyEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} />
default:
return null
}
}

View File

@ -0,0 +1,164 @@
'use client'
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { isEqual } from 'lodash-es'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal/modal'
import Toast from '@/app/components/base/toast'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
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 ManualEditModal = ({ onClose, subscription, pluginDetail }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const { refetch } = useSubscriptionList()
const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription()
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message)
return error.message
if (typeof error === 'object' && error && 'message' in error) {
const message = (error as { message?: string }).message
if (typeof message === 'string' && message)
return message
}
return fallback
}
const propertiesSchema = useMemo<ParametersSchema[]>(
() => detail?.declaration?.trigger?.subscription_schema || [],
[detail?.declaration?.trigger?.subscription_schema],
)
const formRef = useRef<FormRefObject>(null)
const handleConfirm = () => {
const formValues = formRef.current?.getFormValues({
needTransformWhenSecretFieldIsPristine: true,
})
if (!formValues?.isCheckValidated)
return
const name = formValues.values.subscription_name as string
// Extract properties (exclude subscription_name and callback_url)
const newProperties = { ...formValues.values }
delete newProperties.subscription_name
delete newProperties.callback_url
// Only send properties if changed
const hasChanged = !isEqual(newProperties, subscription.properties || {})
const properties = hasChanged ? newProperties : undefined
updateSubscription(
{
subscriptionId: subscription.id,
name,
properties,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('pluginTrigger.subscription.list.item.actions.edit.success'),
})
refetch?.()
onClose()
},
onError: (error: unknown) => {
Toast.notify({
type: 'error',
message: getErrorMessage(error, 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,
},
...propertiesSchema.map((schema: ParametersSchema) => ({
...schema,
type: normalizeFormType(schema.type as string),
tooltip: schema.description,
default: subscription.properties?.[schema.name] || schema.default,
})),
], [t, subscription.name, subscription.endpoint, subscription.properties, propertiesSchema])
return (
<Modal
title={t('pluginTrigger.subscription.list.item.actions.edit.title')}
confirmButtonText={isUpdating ? t('common.operation.saving') : t('common.operation.save')}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isUpdating}
clickOutsideNotClose
wrapperClassName="!z-[101]"
>
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
)}
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
</Modal>
)
}

View File

@ -0,0 +1,178 @@
'use client'
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { isEqual } from 'lodash-es'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal/modal'
import Toast from '@/app/components/base/toast'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { ReadmeShowType } from '../../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
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 getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message)
return error.message
if (typeof error === 'object' && error && 'message' in error) {
const message = (error as { message?: string }).message
if (typeof message === 'string' && message)
return message
}
return fallback
}
const parametersSchema = useMemo<ParametersSchema[]>(
() => detail?.declaration?.trigger?.subscription_constructor?.parameters || [],
[detail?.declaration?.trigger?.subscription_constructor?.parameters],
)
const formRef = useRef<FormRefObject>(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 newParameters = { ...formValues.values }
delete newParameters.subscription_name
delete newParameters.callback_url
// Only send parameters if changed
const hasChanged = !isEqual(newParameters, subscription.parameters || {})
const parameters = hasChanged ? newParameters : 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: unknown) => {
Toast.notify({
type: 'error',
message: getErrorMessage(error, 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 (
<Modal
title={t('pluginTrigger.subscription.list.item.actions.edit.title')}
confirmButtonText={isUpdating ? t('common.operation.saving') : t('common.operation.save')}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isUpdating}
clickOutsideNotClose
wrapperClassName="!z-[101]"
>
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
)}
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
</Modal>
)
}

View File

@ -1,31 +1,27 @@
import type { SimpleSubscription } from './types'
import type { PluginDetail } from '@/app/components/plugins/types'
import { withErrorBoundary } from '@/app/components/base/error-boundary' import { withErrorBoundary } from '@/app/components/base/error-boundary'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { SubscriptionListView } from './list-view' import { SubscriptionListView } from './list-view'
import { SubscriptionSelectorView } from './selector-view' import { SubscriptionSelectorView } from './selector-view'
import { SubscriptionListMode } from './types'
import { useSubscriptionList } from './use-subscription-list' import { useSubscriptionList } from './use-subscription-list'
export enum SubscriptionListMode {
PANEL = 'panel',
SELECTOR = 'selector',
}
export type SimpleSubscription = {
id: string
name: string
}
type SubscriptionListProps = { type SubscriptionListProps = {
mode?: SubscriptionListMode mode?: SubscriptionListMode
selectedId?: string selectedId?: string
onSelect?: (v: SimpleSubscription, callback?: () => void) => void onSelect?: (v: SimpleSubscription, callback?: () => void) => void
pluginDetail?: PluginDetail
} }
export { SubscriptionSelectorEntry } from './selector-entry' export { SubscriptionSelectorEntry } from './selector-entry'
export type { SimpleSubscription } from './types'
export const SubscriptionList = withErrorBoundary(({ export const SubscriptionList = withErrorBoundary(({
mode = SubscriptionListMode.PANEL, mode = SubscriptionListMode.PANEL,
selectedId, selectedId,
onSelect, onSelect,
pluginDetail,
}: SubscriptionListProps) => { }: SubscriptionListProps) => {
const { isLoading, refetch } = useSubscriptionList() const { isLoading, refetch } = useSubscriptionList()
if (isLoading) { if (isLoading) {
@ -47,5 +43,5 @@ export const SubscriptionList = withErrorBoundary(({
) )
} }
return <SubscriptionListView /> return <SubscriptionListView pluginDetail={pluginDetail} />
}) })

View File

@ -1,4 +1,5 @@
'use client' 'use client'
import type { PluginDetail } from '@/app/components/plugins/types'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
@ -9,10 +10,12 @@ import { useSubscriptionList } from './use-subscription-list'
type SubscriptionListViewProps = { type SubscriptionListViewProps = {
showTopBorder?: boolean showTopBorder?: boolean
pluginDetail?: PluginDetail
} }
export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
showTopBorder = false, showTopBorder = false,
pluginDetail,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { subscriptions } = useSubscriptionList() const { subscriptions } = useSubscriptionList()
@ -41,6 +44,7 @@ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
<SubscriptionCard <SubscriptionCard
key={subscription.id} key={subscription.id}
data={subscription} data={subscription}
pluginDetail={pluginDetail}
/> />
))} ))}
</div> </div>

View File

@ -1,5 +1,5 @@
'use client' 'use client'
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' import type { SimpleSubscription } from './types'
import { RiArrowDownSLine, RiWebhookLine } from '@remixicon/react' import { RiArrowDownSLine, RiWebhookLine } from '@remixicon/react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -8,8 +8,9 @@ import {
PortalToFollowElemContent, PortalToFollowElemContent,
PortalToFollowElemTrigger, PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem' } from '@/app/components/base/portal-to-follow-elem'
import { SubscriptionList, SubscriptionListMode } from '@/app/components/plugins/plugin-detail-panel/subscription-list' import { SubscriptionList } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { SubscriptionListMode } from './types'
import { useSubscriptionList } from './use-subscription-list' import { useSubscriptionList } from './use-subscription-list'
type SubscriptionTriggerButtonProps = { type SubscriptionTriggerButtonProps = {

View File

@ -1,7 +1,9 @@
'use client' 'use client'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { import {
RiDeleteBinLine, RiDeleteBinLine,
RiEditLine,
RiWebhookLine, RiWebhookLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
@ -10,17 +12,23 @@ import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { DeleteConfirm } from './delete-confirm' import { DeleteConfirm } from './delete-confirm'
import { EditModal } from './edit'
type Props = { type Props = {
data: TriggerSubscription data: TriggerSubscription
pluginDetail?: PluginDetail
} }
const SubscriptionCard = ({ data }: Props) => { const SubscriptionCard = ({ data, pluginDetail }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isShowDeleteModal, { const [isShowDeleteModal, {
setTrue: showDeleteModal, setTrue: showDeleteModal,
setFalse: hideDeleteModal, setFalse: hideDeleteModal,
}] = useBoolean(false) }] = useBoolean(false)
const [isShowEditModal, {
setTrue: showEditModal,
setFalse: hideEditModal,
}] = useBoolean(false)
return ( return (
<> <>
@ -40,13 +48,21 @@ const SubscriptionCard = ({ data }: Props) => {
</span> </span>
</div> </div>
<div className="hidden items-center gap-1 group-hover:flex">
<ActionButton
onClick={showEditModal}
className="transition-colors hover:bg-state-base-hover"
>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<ActionButton <ActionButton
onClick={showDeleteModal} onClick={showDeleteModal}
className="subscription-delete-btn hidden transition-colors hover:bg-state-destructive-hover hover:text-text-destructive group-hover:block" className="subscription-delete-btn transition-colors hover:bg-state-destructive-hover hover:text-text-destructive"
> >
<RiDeleteBinLine className="h-4 w-4" /> <RiDeleteBinLine className="h-4 w-4" />
</ActionButton> </ActionButton>
</div> </div>
</div>
<div className="mt-1 flex items-center justify-between"> <div className="mt-1 flex items-center justify-between">
<Tooltip <Tooltip
@ -78,6 +94,14 @@ const SubscriptionCard = ({ data }: Props) => {
workflowsInUse={data.workflows_in_use} workflowsInUse={data.workflows_in_use}
/> />
)} )}
{isShowEditModal && (
<EditModal
onClose={hideEditModal}
subscription={data}
pluginDetail={pluginDetail}
/>
)}
</> </>
) )
} }

View File

@ -0,0 +1,9 @@
export enum SubscriptionListMode {
PANEL = 'panel',
SELECTOR = 'selector',
}
export type SimpleSubscription = {
id: string
name: string
}

View File

@ -131,7 +131,7 @@ export type ParametersSchema = {
scope: any scope: any
required: boolean required: boolean
multiple: boolean multiple: boolean
default?: string[] default?: string | string[]
min: any min: any
max: any max: any
precision: any precision: any

View File

@ -39,9 +39,9 @@ export type TriggerDefaultValue = PluginCommonDefaultValue & {
title: string title: string
plugin_unique_identifier: string plugin_unique_identifier: string
is_team_authorization: boolean is_team_authorization: boolean
params: Record<string, any> params: Record<string, unknown>
paramSchemas: Record<string, any>[] paramSchemas: Record<string, unknown>[]
output_schema: Record<string, any> output_schema: Record<string, unknown>
subscription_id?: string subscription_id?: string
meta?: PluginMeta meta?: PluginMeta
} }
@ -52,9 +52,9 @@ export type ToolDefaultValue = PluginCommonDefaultValue & {
tool_description: string tool_description: string
title: string title: string
is_team_authorization: boolean is_team_authorization: boolean
params: Record<string, any> params: Record<string, unknown>
paramSchemas: Record<string, any>[] paramSchemas: Record<string, unknown>[]
output_schema?: Record<string, any> output_schema?: Record<string, unknown>
credential_id?: string credential_id?: string
meta?: PluginMeta meta?: PluginMeta
plugin_id?: string plugin_id?: string
@ -82,10 +82,10 @@ export type ToolValue = {
tool_name: string tool_name: string
tool_label: string tool_label: string
tool_description?: string tool_description?: string
settings?: Record<string, any> settings?: Record<string, unknown>
parameters?: Record<string, any> parameters?: Record<string, unknown>
enabled?: boolean enabled?: boolean
extra?: Record<string, any> extra?: { description?: string } & Record<string, unknown>
credential_id?: string credential_id?: string
} }
@ -94,7 +94,7 @@ export type DataSourceItem = {
plugin_unique_identifier: string plugin_unique_identifier: string
provider: string provider: string
declaration: { declaration: {
credentials_schema: any[] credentials_schema: unknown[]
provider_type: string provider_type: string
identity: { identity: {
author: string author: string
@ -113,10 +113,10 @@ export type DataSourceItem = {
name: string name: string
provider: string provider: string
} }
parameters: any[] parameters: unknown[]
output_schema?: { output_schema?: {
type: string type: string
properties: Record<string, any> properties: Record<string, unknown>
} }
}[] }[]
} }
@ -133,15 +133,15 @@ export type TriggerParameter = {
| 'model-selector' | 'app-selector' | 'object' | 'array' | 'dynamic-select' | 'model-selector' | 'app-selector' | 'object' | 'array' | 'dynamic-select'
auto_generate?: { auto_generate?: {
type: string type: string
value?: any value?: unknown
} | null } | null
template?: { template?: {
type: string type: string
value?: any value?: unknown
} | null } | null
scope?: string | null scope?: string | null
required?: boolean required?: boolean
default?: any default?: unknown
min?: number | null min?: number | null
max?: number | null max?: number | null
precision?: number | null precision?: number | null
@ -158,7 +158,7 @@ export type TriggerCredentialField = {
name: string name: string
scope?: string | null scope?: string | null
required: boolean required: boolean
default?: string | number | boolean | Array<any> | null default?: string | number | boolean | Array<unknown> | null
options?: Array<{ options?: Array<{
value: string value: string
label: TypeWithI18N label: TypeWithI18N
@ -191,7 +191,7 @@ export type TriggerApiEntity = {
identity: TriggerIdentity identity: TriggerIdentity
description: TypeWithI18N description: TypeWithI18N
parameters: TriggerParameter[] parameters: TriggerParameter[]
output_schema?: Record<string, any> output_schema?: Record<string, unknown>
} }
export type TriggerProviderApiEntity = { export type TriggerProviderApiEntity = {
@ -237,32 +237,15 @@ type TriggerSubscriptionStructure = {
name: string name: string
provider: string provider: string
credential_type: TriggerCredentialTypeEnum credential_type: TriggerCredentialTypeEnum
credentials: TriggerSubCredentials credentials: Record<string, unknown>
endpoint: string endpoint: string
parameters: TriggerSubParameters parameters: Record<string, unknown>
properties: TriggerSubProperties properties: Record<string, unknown>
workflows_in_use: number workflows_in_use: number
} }
export type TriggerSubscription = TriggerSubscriptionStructure export type TriggerSubscription = TriggerSubscriptionStructure
export type TriggerSubCredentials = {
access_tokens: string
}
export type TriggerSubParameters = {
repository: string
webhook_secret?: string
}
export type TriggerSubProperties = {
active: boolean
events: string[]
external_id: string
repository: string
webhook_secret?: string
}
export type TriggerSubscriptionBuilder = TriggerSubscriptionStructure export type TriggerSubscriptionBuilder = TriggerSubscriptionStructure
// OAuth configuration types // OAuth configuration types
@ -275,7 +258,7 @@ export type TriggerOAuthConfig = {
params: { params: {
client_id: string client_id: string
client_secret: string client_secret: string
[key: string]: any [key: string]: string
} }
system_configured: boolean system_configured: boolean
} }

View File

@ -4,11 +4,11 @@ import {
useBuildTriggerSubscription, useBuildTriggerSubscription,
useCreateTriggerSubscriptionBuilder, useCreateTriggerSubscriptionBuilder,
useUpdateTriggerSubscriptionBuilder, useUpdateTriggerSubscriptionBuilder,
useVerifyTriggerSubscriptionBuilder, useVerifyAndUpdateTriggerSubscriptionBuilder,
} from '@/service/use-triggers' } from '@/service/use-triggers'
// Helper function to serialize complex values to strings for backend encryption // Helper function to serialize complex values to strings for backend encryption
const serializeFormValues = (values: Record<string, any>): Record<string, string> => { const serializeFormValues = (values: Record<string, unknown>): Record<string, string> => {
const result: Record<string, string> = {} const result: Record<string, string> = {}
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
@ -23,6 +23,17 @@ const serializeFormValues = (values: Record<string, any>): Record<string, string
return result return result
} }
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message)
return error.message
if (typeof error === 'object' && error && 'message' in error) {
const message = (error as { message?: string }).message
if (typeof message === 'string' && message)
return message
}
return fallback
}
export type AuthFlowStep = 'auth' | 'params' | 'complete' export type AuthFlowStep = 'auth' | 'params' | 'complete'
export type AuthFlowState = { export type AuthFlowState = {
@ -34,8 +45,8 @@ export type AuthFlowState = {
export type AuthFlowActions = { export type AuthFlowActions = {
startAuth: () => Promise<void> startAuth: () => Promise<void>
verifyAuth: (credentials: Record<string, any>) => Promise<void> verifyAuth: (credentials: Record<string, unknown>) => Promise<void>
completeConfig: (parameters: Record<string, any>, properties?: Record<string, any>, name?: string) => Promise<void> completeConfig: (parameters: Record<string, unknown>, properties?: Record<string, unknown>, name?: string) => Promise<void>
reset: () => void reset: () => void
} }
@ -47,7 +58,7 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState
const createBuilder = useCreateTriggerSubscriptionBuilder() const createBuilder = useCreateTriggerSubscriptionBuilder()
const updateBuilder = useUpdateTriggerSubscriptionBuilder() const updateBuilder = useUpdateTriggerSubscriptionBuilder()
const verifyBuilder = useVerifyTriggerSubscriptionBuilder() const verifyBuilder = useVerifyAndUpdateTriggerSubscriptionBuilder()
const buildSubscription = useBuildTriggerSubscription() const buildSubscription = useBuildTriggerSubscription()
const startAuth = useCallback(async () => { const startAuth = useCallback(async () => {
@ -64,8 +75,8 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState
setBuilderId(response.subscription_builder.id) setBuilderId(response.subscription_builder.id)
setStep('auth') setStep('auth')
} }
catch (err: any) { catch (err: unknown) {
setError(err.message || 'Failed to start authentication flow') setError(getErrorMessage(err, 'Failed to start authentication flow'))
throw err throw err
} }
finally { finally {
@ -73,7 +84,7 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState
} }
}, [provider.name, createBuilder, builderId]) }, [provider.name, createBuilder, builderId])
const verifyAuth = useCallback(async (credentials: Record<string, any>) => { const verifyAuth = useCallback(async (credentials: Record<string, unknown>) => {
if (!builderId) { if (!builderId) {
setError('No builder ID available') setError('No builder ID available')
return return
@ -96,8 +107,8 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState
setStep('params') setStep('params')
} }
catch (err: any) { catch (err: unknown) {
setError(err.message || 'Authentication verification failed') setError(getErrorMessage(err, 'Authentication verification failed'))
throw err throw err
} }
finally { finally {
@ -106,8 +117,8 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState
}, [provider.name, builderId, updateBuilder, verifyBuilder]) }, [provider.name, builderId, updateBuilder, verifyBuilder])
const completeConfig = useCallback(async ( const completeConfig = useCallback(async (
parameters: Record<string, any>, parameters: Record<string, unknown>,
properties: Record<string, any> = {}, properties: Record<string, unknown> = {},
name?: string, name?: string,
) => { ) => {
if (!builderId) { if (!builderId) {
@ -134,8 +145,8 @@ export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState
setStep('complete') setStep('complete')
} }
catch (err: any) { catch (err: unknown) {
setError(err.message || 'Configuration failed') setError(getErrorMessage(err, 'Configuration failed'))
throw err throw err
} }
finally { finally {

View File

@ -21,6 +21,7 @@ const translation = {
cancel: 'Cancel', cancel: 'Cancel',
clear: 'Clear', clear: 'Clear',
save: 'Save', save: 'Save',
saving: 'Saving...',
yes: 'Yes', yes: 'Yes',
no: 'No', no: 'No',
deleteConfirmTitle: 'Delete?', deleteConfirmTitle: 'Delete?',

View File

@ -30,6 +30,11 @@ const translation = {
unauthorized: 'Manual', unauthorized: 'Manual',
}, },
actions: { actions: {
edit: {
title: 'Edit Subscription',
success: 'Subscription updated successfully',
error: 'Failed to update subscription',
},
delete: 'Delete', delete: 'Delete',
deleteConfirm: { deleteConfirm: {
title: 'Delete {{name}}?', title: 'Delete {{name}}?',

View File

@ -1,3 +1,4 @@
import type { FormOption } from '@/app/components/base/form/types'
import type { import type {
TriggerLogEntity, TriggerLogEntity,
TriggerOAuthClientParams, TriggerOAuthClientParams,
@ -149,9 +150,9 @@ export const useUpdateTriggerSubscriptionBuilder = () => {
provider: string provider: string
subscriptionBuilderId: string subscriptionBuilderId: string
name?: string name?: string
properties?: Record<string, any> properties?: Record<string, unknown>
parameters?: Record<string, any> parameters?: Record<string, unknown>
credentials?: Record<string, any> credentials?: Record<string, unknown>
}) => { }) => {
const { provider, subscriptionBuilderId, ...body } = payload const { provider, subscriptionBuilderId, ...body } = payload
return post<TriggerSubscriptionBuilder>( return post<TriggerSubscriptionBuilder>(
@ -162,17 +163,35 @@ export const useUpdateTriggerSubscriptionBuilder = () => {
}) })
} }
export const useVerifyTriggerSubscriptionBuilder = () => { export const useVerifyAndUpdateTriggerSubscriptionBuilder = () => {
return useMutation({ return useMutation({
mutationKey: [NAME_SPACE, 'verify-subscription-builder'], mutationKey: [NAME_SPACE, 'verify-and-update-subscription-builder'],
mutationFn: (payload: { mutationFn: (payload: {
provider: string provider: string
subscriptionBuilderId: string subscriptionBuilderId: string
credentials?: Record<string, any> credentials?: Record<string, unknown>
}) => { }) => {
const { provider, subscriptionBuilderId, ...body } = payload const { provider, subscriptionBuilderId, ...body } = payload
return post<{ verified: boolean }>( return post<{ verified: boolean }>(
`/workspaces/current/trigger-provider/${provider}/subscriptions/builder/verify/${subscriptionBuilderId}`, `/workspaces/current/trigger-provider/${provider}/subscriptions/builder/verify-and-update/${subscriptionBuilderId}`,
{ body },
{ silent: true },
)
},
})
}
export const useVerifyTriggerSubscription = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'verify-subscription'],
mutationFn: (payload: {
provider: string
subscriptionId: string
credentials?: Record<string, unknown>
}) => {
const { provider, subscriptionId, ...body } = payload
return post<{ verified: boolean }>(
`/workspaces/current/trigger-provider/${provider}/subscriptions/verify/${subscriptionId}`,
{ body }, { body },
{ silent: true }, { silent: true },
) )
@ -184,7 +203,7 @@ export type BuildTriggerSubscriptionPayload = {
provider: string provider: string
subscriptionBuilderId: string subscriptionBuilderId: string
name?: string name?: string
parameters?: Record<string, any> parameters?: Record<string, unknown>
} }
export const useBuildTriggerSubscription = () => { export const useBuildTriggerSubscription = () => {
@ -211,6 +230,27 @@ export const useDeleteTriggerSubscription = () => {
}) })
} }
export type UpdateTriggerSubscriptionPayload = {
subscriptionId: string
name?: string
properties?: Record<string, unknown>
parameters?: Record<string, unknown>
credentials?: Record<string, unknown>
}
export const useUpdateTriggerSubscription = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'update-subscription'],
mutationFn: (payload: UpdateTriggerSubscriptionPayload) => {
const { subscriptionId, ...body } = payload
return post<{ result: string, id: string }>(
`/workspaces/current/trigger-provider/${subscriptionId}/subscriptions/update`,
{ body },
)
},
})
}
export const useTriggerSubscriptionBuilderLogs = ( export const useTriggerSubscriptionBuilderLogs = (
provider: string, provider: string,
subscriptionBuilderId: string, subscriptionBuilderId: string,
@ -290,20 +330,45 @@ export const useTriggerPluginDynamicOptions = (payload: {
action: string action: string
parameter: string parameter: string
credential_id: string credential_id: string
extra?: Record<string, any> credentials?: Record<string, unknown>
extra?: Record<string, unknown>
}, enabled = true) => { }, enabled = true) => {
return useQuery<{ options: Array<{ value: string, label: any }> }>({ return useQuery<{ options: FormOption[] }>({
queryKey: [NAME_SPACE, 'dynamic-options', payload.plugin_id, payload.provider, payload.action, payload.parameter, payload.credential_id, payload.extra], queryKey: [NAME_SPACE, 'dynamic-options', payload.plugin_id, payload.provider, payload.action, payload.parameter, payload.credential_id, payload.credentials, payload.extra],
queryFn: () => get<{ options: Array<{ value: string, label: any }> }>( queryFn: () => {
'/workspaces/current/plugin/parameters/dynamic-options', // Use new endpoint with POST when credentials provided (for edit mode)
if (payload.credentials) {
return post<{ options: FormOption[] }>(
'/workspaces/current/plugin/parameters/dynamic-options-with-credentials',
{ {
params: { body: {
...payload, plugin_id: payload.plugin_id,
provider_type: 'trigger', // Add required provider_type parameter provider: payload.provider,
action: payload.action,
parameter: payload.parameter,
credential_id: payload.credential_id,
credentials: payload.credentials,
}, },
}, },
{ silent: true }, { silent: true },
), )
}
// Use original GET endpoint for normal cases
return get<{ options: FormOption[] }>(
'/workspaces/current/plugin/parameters/dynamic-options',
{
params: {
plugin_id: payload.plugin_id,
provider: payload.provider,
action: payload.action,
parameter: payload.parameter,
credential_id: payload.credential_id,
provider_type: 'trigger',
},
},
{ silent: true },
)
},
enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter && !!payload.credential_id, enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter && !!payload.credential_id,
retry: 0, retry: 0,
}) })