Compare commits

..

46 Commits

Author SHA1 Message Date
1ce330eeed fix test 2026-02-06 11:42:07 +08:00
ee8ff23482 fix style check 2026-02-06 11:26:41 +08:00
fa6f6730b5 add queue api_token_update 2026-02-06 11:08:12 +08:00
b0876e0ec8 add queue api_token_update 2026-02-06 11:08:09 +08:00
4763375c61 fix style check 2026-02-06 10:53:44 +08:00
63248ed088 [autofix.ci] apply automated fixes 2026-02-06 02:42:42 +00:00
18d66b3262 Modify to synchronize redis data to db regularly. 2026-02-06 10:40:44 +08:00
ce3fdb604d Modify to synchronize redis data to db regularly. 2026-02-06 10:40:39 +08:00
57f76c4072 [autofix.ci] apply automated fixes 2026-02-06 02:11:04 +00:00
cb2b3e07ba fix: Standardized adjustment 2026-02-06 10:08:46 +08:00
8cbd1af0d1 Merge remote-tracking branch 'origin/main' into fix/api-token-lock 2026-02-04 16:09:10 +08:00
0d74ac634b fix: missing import console_ns (#31916) 2026-02-04 16:08:00 +08:00
402b0e2cd6 Merge remote-tracking branch 'origin/main' into fix/api-token-lock 2026-02-04 15:55:38 +08:00
07f2a40802 [autofix.ci] apply automated fixes 2026-02-04 07:50:51 +00:00
93b535be95 make it great agin 2026-02-04 15:46:30 +08:00
13be706202 make it great agin 2026-02-04 15:46:24 +08:00
468990cc39 fix: remove api reference doc link en prefix (#31910) 2026-02-04 14:58:26 +08:00
64e769f96e refactor: plugin detail panel components for better maintainability and code organization. (#31870)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-02-04 14:51:47 +08:00
778aabb485 refactor(api): replace reqparse with Pydantic models in trial.py (#31789)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-02-04 14:36:52 +08:00
0685e294c4 [autofix.ci] apply automated fixes 2026-02-04 05:34:58 +00:00
f8cc056604 fix start_time -> update_time 2026-02-04 13:30:45 +08:00
ef1e233c2d [autofix.ci] apply automated fixes 2026-02-04 05:27:16 +00:00
6d87424ab8 fix start_time -> update_time 2026-02-04 13:18:47 +08:00
aaa98c9550 Merge branch 'fix/api-token-lock' of github.com:langgenius/dify into fix/api-token-lock 2026-02-04 12:20:51 +08:00
4719f2569c fix start_time -> update_time 2026-02-04 12:20:42 +08:00
282ec583db [autofix.ci] apply automated fixes 2026-02-04 04:06:50 +00:00
c7337d5b67 make it great agin 2026-02-04 12:02:43 +08:00
e1efea16a4 make it great agin 2026-02-04 12:00:43 +08:00
dcba86b707 [autofix.ci] apply automated fixes 2026-02-04 03:22:17 +00:00
d02ed82854 Merge branch 'fix/api-token-lock' of github.com:langgenius/dify into fix/api-token-lock 2026-02-04 11:18:03 +08:00
60e3a7b419 make it great agin 2026-02-04 11:17:37 +08:00
240684e723 make it great agin 2026-02-04 11:17:30 +08:00
edfd34bc90 [autofix.ci] apply automated fixes 2026-02-03 08:32:34 +00:00
292a9ff487 fix linter 2026-02-03 16:28:28 +08:00
132684898b fix linter 2026-02-03 16:26:52 +08:00
138117526a [autofix.ci] apply automated fixes 2026-02-03 07:53:38 +00:00
396834c808 fix linter 2026-02-03 15:48:55 +08:00
657b3f5990 [autofix.ci] apply automated fixes 2026-02-03 07:29:15 +00:00
ea5089aba7 fix linter 2026-02-03 15:25:15 +08:00
d69e4de47b fix linter 2026-02-03 15:22:01 +08:00
79ead90487 Update api/tasks/update_api_token_last_used_task.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-03 15:19:59 +08:00
c7de79dcbf Update api/libs/api_token_cache.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-03 15:16:29 +08:00
da9abcf885 Merge branch 'fix/api-token-lock' of github.com:langgenius/dify into fix/api-token-lock 2026-02-03 15:15:01 +08:00
d54b08701e fix linter 2026-02-03 15:14:17 +08:00
c2fdfdc504 [autofix.ci] apply automated fixes 2026-02-03 07:08:47 +00:00
d58d3f5bde add redis for api token 2026-02-03 15:03:11 +08:00
43 changed files with 5716 additions and 1441 deletions

View File

@ -122,7 +122,7 @@ These commands assume you start from the repository root.
```bash
cd api
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q api_token_update,dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention
```
1. Optional: start Celery Beat (scheduled tasks, in a new terminal).

View File

@ -1155,6 +1155,16 @@ class CeleryScheduleTasksConfig(BaseSettings):
default=0,
)
# API token last_used_at batch update
ENABLE_API_TOKEN_LAST_USED_UPDATE_TASK: bool = Field(
description="Enable periodic batch update of API token last_used_at timestamps",
default=True,
)
API_TOKEN_LAST_USED_UPDATE_INTERVAL: int = Field(
description="Interval in minutes for batch updating API token last_used_at (default 30)",
default=30,
)
# Trigger provider refresh (simple version)
ENABLE_TRIGGER_PROVIDER_REFRESH_TASK: bool = Field(
description="Enable trigger provider refresh poller",

View File

@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from extensions.ext_database import db
from libs.api_token_cache import ApiTokenCache
from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required
from models.dataset import Dataset
@ -131,6 +132,11 @@ class BaseApiKeyResource(Resource):
if key is None:
flask_restx.abort(HTTPStatus.NOT_FOUND, message="API key not found")
# Invalidate cache before deleting from database
# Type assertion: key is guaranteed to be non-None here because abort() raises
assert key is not None # nosec - for type checker only
ApiTokenCache.delete(key.token, key.type)
db.session.query(ApiToken).where(ApiToken.id == api_key_id).delete()
db.session.commit()

View File

@ -51,6 +51,7 @@ from fields.dataset_fields import (
weighted_score_fields,
)
from fields.document_fields import document_status_fields
from libs.api_token_cache import ApiTokenCache
from libs.login import current_account_with_tenant, login_required
from models import ApiToken, Dataset, Document, DocumentSegment, UploadFile
from models.dataset import DatasetPermissionEnum
@ -820,6 +821,11 @@ class DatasetApiDeleteApi(Resource):
if key is None:
console_ns.abort(404, message="API key not found")
# Invalidate cache before deleting from database
# Type assertion: key is guaranteed to be non-None here because abort() raises
assert key is not None # nosec - for type checker only
ApiTokenCache.delete(key.token, key.type)
db.session.query(ApiToken).where(ApiToken.id == api_key_id).delete()
db.session.commit()

View File

@ -1,15 +1,16 @@
import logging
from typing import Any, cast
from typing import Any, Literal, cast
from flask import request
from flask_restx import Resource, fields, marshal, marshal_with, reqparse
from flask_restx import Resource, fields, marshal, marshal_with
from pydantic import BaseModel
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
from controllers.common.fields import Parameters as ParametersResponse
from controllers.common.fields import Site as SiteResponse
from controllers.common.schema import get_or_create_model
from controllers.console import api
from controllers.console import api, console_ns
from controllers.console.app.error import (
AppUnavailableError,
AudioTooLargeError,
@ -117,7 +118,56 @@ workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipel
workflow_model = get_or_create_model("TrialWorkflow", workflow_fields_copy)
# Pydantic models for request validation
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class WorkflowRunRequest(BaseModel):
inputs: dict
files: list | None = None
class ChatRequest(BaseModel):
inputs: dict
query: str
files: list | None = None
conversation_id: str | None = None
parent_message_id: str | None = None
retriever_from: str = "explore_app"
class TextToSpeechRequest(BaseModel):
message_id: str | None = None
voice: str | None = None
text: str | None = None
streaming: bool | None = None
class CompletionRequest(BaseModel):
inputs: dict
query: str = ""
files: list | None = None
response_mode: Literal["blocking", "streaming"] | None = None
retriever_from: str = "explore_app"
# Register schemas for Swagger documentation
console_ns.schema_model(
WorkflowRunRequest.__name__, WorkflowRunRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
)
console_ns.schema_model(
ChatRequest.__name__, ChatRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
)
console_ns.schema_model(
TextToSpeechRequest.__name__, TextToSpeechRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
)
console_ns.schema_model(
CompletionRequest.__name__, CompletionRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
)
class TrialAppWorkflowRunApi(TrialAppResource):
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
def post(self, trial_app):
"""
Run workflow
@ -129,10 +179,8 @@ class TrialAppWorkflowRunApi(TrialAppResource):
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
parser = reqparse.RequestParser()
parser.add_argument("inputs", type=dict, required=True, nullable=False, location="json")
parser.add_argument("files", type=list, required=False, location="json")
args = parser.parse_args()
request_data = WorkflowRunRequest.model_validate(console_ns.payload)
args = request_data.model_dump()
assert current_user is not None
try:
app_id = app_model.id
@ -183,6 +231,7 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
class TrialChatApi(TrialAppResource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@trial_feature_enable
def post(self, trial_app):
app_model = trial_app
@ -190,14 +239,14 @@ class TrialChatApi(TrialAppResource):
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
parser = reqparse.RequestParser()
parser.add_argument("inputs", type=dict, required=True, location="json")
parser.add_argument("query", type=str, required=True, location="json")
parser.add_argument("files", type=list, required=False, location="json")
parser.add_argument("conversation_id", type=uuid_value, location="json")
parser.add_argument("parent_message_id", type=uuid_value, required=False, location="json")
parser.add_argument("retriever_from", type=str, required=False, default="explore_app", location="json")
args = parser.parse_args()
request_data = ChatRequest.model_validate(console_ns.payload)
args = request_data.model_dump()
# Validate UUID values if provided
if args.get("conversation_id"):
args["conversation_id"] = uuid_value(args["conversation_id"])
if args.get("parent_message_id"):
args["parent_message_id"] = uuid_value(args["parent_message_id"])
args["auto_generate_name"] = False
@ -320,20 +369,16 @@ class TrialChatAudioApi(TrialAppResource):
class TrialChatTextApi(TrialAppResource):
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
@trial_feature_enable
def post(self, trial_app):
app_model = trial_app
try:
parser = reqparse.RequestParser()
parser.add_argument("message_id", type=str, required=False, location="json")
parser.add_argument("voice", type=str, location="json")
parser.add_argument("text", type=str, location="json")
parser.add_argument("streaming", type=bool, location="json")
args = parser.parse_args()
request_data = TextToSpeechRequest.model_validate(console_ns.payload)
message_id = args.get("message_id", None)
text = args.get("text", None)
voice = args.get("voice", None)
message_id = request_data.message_id
text = request_data.text
voice = request_data.voice
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
@ -371,19 +416,15 @@ class TrialChatTextApi(TrialAppResource):
class TrialCompletionApi(TrialAppResource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@trial_feature_enable
def post(self, trial_app):
app_model = trial_app
if app_model.mode != "completion":
raise NotCompletionAppError()
parser = reqparse.RequestParser()
parser.add_argument("inputs", type=dict, required=True, location="json")
parser.add_argument("query", type=str, location="json", default="")
parser.add_argument("files", type=list, required=False, location="json")
parser.add_argument("response_mode", type=str, choices=["blocking", "streaming"], location="json")
parser.add_argument("retriever_from", type=str, required=False, default="explore_app", location="json")
args = parser.parse_args()
request_data = CompletionRequest.model_validate(console_ns.payload)
args = request_data.model_dump()
streaming = args["response_mode"] == "streaming"
args["auto_generate_name"] = False

View File

@ -1,22 +1,22 @@
import logging
import time
from collections.abc import Callable
from datetime import timedelta
from enum import StrEnum, auto
from functools import wraps
from typing import Concatenate, ParamSpec, TypeVar
from typing import Concatenate, ParamSpec, TypeVar, cast
from flask import current_app, request
from flask_login import user_logged_in
from flask_restx import Resource
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.api_token_cache import ApiTokenCache
from libs.datetime_utils import naive_utc_now
from libs.login import current_user
from models import Account, Tenant, TenantAccountJoin, TenantStatus
@ -296,7 +296,14 @@ def validate_dataset_token(view: Callable[Concatenate[T, P], R] | None = None):
def validate_and_get_api_token(scope: str | None = None):
"""
Validate and get API token.
Validate and get API token with Redis caching.
This function uses a two-tier approach:
1. First checks Redis cache for the token
2. If not cached, queries database and caches the result
The last_used_at field is updated asynchronously via Celery task
to avoid blocking the request.
"""
auth_header = request.headers.get("Authorization")
if auth_header is None or " " not in auth_header:
@ -308,29 +315,91 @@ def validate_and_get_api_token(scope: str | None = None):
if auth_scheme != "bearer":
raise Unauthorized("Authorization scheme must be 'Bearer'")
current_time = naive_utc_now()
cutoff_time = current_time - timedelta(minutes=1)
# Try to get token from cache first
# Returns a CachedApiToken (plain Python object), not a SQLAlchemy model
cached_token = ApiTokenCache.get(auth_token, scope)
if cached_token is not None:
logger.debug("Token validation served from cache for scope: %s", scope)
# Record usage in Redis for later batch update (no Celery task per request)
_record_token_usage(auth_token, scope)
return cast(ApiToken, cached_token)
# Cache miss - use Redis lock for single-flight mode
# This ensures only one request queries DB for the same token concurrently
return _fetch_token_with_single_flight(auth_token, scope)
def _query_token_from_db(auth_token: str, scope: str | None) -> ApiToken:
"""
Query API token from database and cache the result.
last_used_at is NOT updated here -- it is handled by the periodic batch
task via _record_token_usage().
Raises Unauthorized if token is invalid.
"""
with Session(db.engine, expire_on_commit=False) as session:
update_stmt = (
update(ApiToken)
.where(
ApiToken.token == auth_token,
(ApiToken.last_used_at.is_(None) | (ApiToken.last_used_at < cutoff_time)),
ApiToken.type == scope,
)
.values(last_used_at=current_time)
)
stmt = select(ApiToken).where(ApiToken.token == auth_token, ApiToken.type == scope)
result = session.execute(update_stmt)
api_token = session.scalar(stmt)
if hasattr(result, "rowcount") and result.rowcount > 0:
session.commit()
if not api_token:
ApiTokenCache.set(auth_token, scope, None)
raise Unauthorized("Access token is invalid")
return api_token
ApiTokenCache.set(auth_token, scope, api_token)
# Record usage for later batch update
_record_token_usage(auth_token, scope)
return api_token
def _fetch_token_with_single_flight(auth_token: str, scope: str | None) -> ApiToken:
"""
Fetch token from DB with single-flight pattern using Redis lock.
Ensures only one concurrent request queries the database for the same token.
Falls back to direct query if lock acquisition fails.
"""
logger.debug("Token cache miss, attempting to acquire query lock for scope: %s", scope)
lock_key = f"api_token_query_lock:{scope}:{auth_token}"
lock = redis_client.lock(lock_key, timeout=10, blocking_timeout=5)
try:
if lock.acquire(blocking=True):
try:
# Double-check cache after acquiring lock
# (another concurrent request might have already cached it)
cached_token = ApiTokenCache.get(auth_token, scope)
if cached_token is not None:
logger.debug("Token cached by concurrent request, using cached version")
return cast(ApiToken, cached_token)
return _query_token_from_db(auth_token, scope)
finally:
lock.release()
else:
logger.warning("Lock timeout for token: %s, proceeding with direct query", auth_token[:10])
return _query_token_from_db(auth_token, scope)
except Unauthorized:
raise
except Exception as e:
logger.warning("Redis lock failed for token query: %s, proceeding anyway", e)
return _query_token_from_db(auth_token, scope)
def _record_token_usage(auth_token: str, scope: str | None):
"""
Record token usage in Redis for later batch update by a scheduled job.
Instead of dispatching a Celery task per request, we simply SET a key in Redis.
A Celery Beat scheduled task will periodically scan these keys and batch-update
last_used_at in the database.
"""
try:
key = f"api_token_active:{scope}:{auth_token}"
redis_client.set(key, naive_utc_now().isoformat(), ex=3600) # TTL 1 hour as safety net
except Exception as e:
logger.warning("Failed to record token usage: %s", e)
class DatasetApiResource(Resource):

View File

@ -35,10 +35,10 @@ if [[ "${MODE}" == "worker" ]]; then
if [[ -z "${CELERY_QUEUES}" ]]; then
if [[ "${EDITION}" == "CLOUD" ]]; then
# Cloud edition: separate queues for dataset and trigger tasks
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
DEFAULT_QUEUES="api_token_update,dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
else
# Community edition (SELF_HOSTED): dataset, pipeline and workflow have separate queues
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
DEFAULT_QUEUES="api_token_update,dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
fi
else
DEFAULT_QUEUES="${CELERY_QUEUES}"

View File

@ -184,6 +184,14 @@ def init_app(app: DifyApp) -> Celery:
"task": "schedule.trigger_provider_refresh_task.trigger_provider_refresh",
"schedule": timedelta(minutes=dify_config.TRIGGER_PROVIDER_REFRESH_INTERVAL),
}
if dify_config.ENABLE_API_TOKEN_LAST_USED_UPDATE_TASK:
imports.append("schedule.update_api_token_last_used_task")
beat_schedule["batch_update_api_token_last_used"] = {
"task": "schedule.update_api_token_last_used_task.batch_update_api_token_last_used",
"schedule": timedelta(minutes=dify_config.API_TOKEN_LAST_USED_UPDATE_INTERVAL),
}
celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)
return celery_app

277
api/libs/api_token_cache.py Normal file
View File

@ -0,0 +1,277 @@
"""
API Token Cache Module
Provides Redis-based caching for API token validation to reduce database load.
"""
import logging
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from extensions.ext_redis import redis_client, redis_fallback
logger = logging.getLogger(__name__)
class CachedApiToken(BaseModel):
"""
Pydantic model for cached API token data.
This is NOT a SQLAlchemy model instance, but a plain Pydantic model
that mimics the ApiToken model interface for read-only access.
Using Pydantic provides:
- Automatic type validation
- Better IDE support
- Built-in serialization/deserialization
"""
id: str
app_id: str | None
tenant_id: str | None
type: str
token: str
last_used_at: datetime | None
created_at: datetime | None
def __repr__(self) -> str:
return f"<CachedApiToken id={self.id} type={self.type}>"
# Cache configuration
CACHE_KEY_PREFIX = "api_token"
CACHE_TTL_SECONDS = 600 # 10 minutes
CACHE_NULL_TTL_SECONDS = 60 # 1 minute for non-existent tokens
class ApiTokenCache:
"""
Redis cache wrapper for API tokens.
Handles serialization, deserialization, and cache invalidation.
"""
@staticmethod
def _make_cache_key(token: str, scope: str | None = None) -> str:
"""
Generate cache key for the given token and scope.
Args:
token: The API token string
scope: The token type/scope (e.g., 'app', 'dataset')
Returns:
Cache key string
"""
scope_str = scope or "any"
return f"{CACHE_KEY_PREFIX}:{scope_str}:{token}"
@staticmethod
def _serialize_token(api_token: Any) -> bytes:
"""
Serialize ApiToken object to JSON bytes.
Args:
api_token: ApiToken model instance or CachedApiToken
Returns:
JSON bytes representation
"""
# If it's already a Pydantic model, use model_dump_json directly
if isinstance(api_token, CachedApiToken):
return api_token.model_dump_json().encode("utf-8")
# Otherwise, convert from SQLAlchemy model to CachedApiToken first
cached = CachedApiToken(
id=str(api_token.id),
app_id=str(api_token.app_id) if api_token.app_id else None,
tenant_id=str(api_token.tenant_id) if api_token.tenant_id else None,
type=api_token.type,
token=api_token.token,
last_used_at=api_token.last_used_at,
created_at=api_token.created_at,
)
return cached.model_dump_json().encode("utf-8")
@staticmethod
def _deserialize_token(cached_data: bytes | str) -> Any:
"""
Deserialize JSON bytes/string back to a CachedApiToken Pydantic model.
Args:
cached_data: JSON bytes or string from cache
Returns:
CachedApiToken instance or None
"""
if cached_data in {b"null", "null"}:
# Cached null value (token doesn't exist)
return None
try:
# Pydantic's model_validate_json handles both bytes and str
if isinstance(cached_data, bytes):
cached_data = cached_data.decode("utf-8")
return CachedApiToken.model_validate_json(cached_data)
except (ValueError, Exception) as e:
logger.warning("Failed to deserialize token from cache: %s", e)
return None
@staticmethod
@redis_fallback(default_return=None)
def get(token: str, scope: str | None) -> Any | None:
"""
Get API token from cache.
Args:
token: The API token string
scope: The token type/scope
Returns:
CachedApiToken instance if found in cache, None if not cached or cache miss
"""
cache_key = ApiTokenCache._make_cache_key(token, scope)
cached_data = redis_client.get(cache_key)
if cached_data is None:
logger.debug("Cache miss for token key: %s", cache_key)
return None
# Pydantic handles deserialization
logger.debug("Cache hit for token key: %s", cache_key)
return ApiTokenCache._deserialize_token(cached_data)
@staticmethod
def _add_to_tenant_index(tenant_id: str | None, cache_key: str) -> None:
"""
Add cache key to tenant index for efficient invalidation.
Maintains a Redis SET: tenant_tokens:{tenant_id} containing all cache keys
for that tenant. This allows O(1) tenant-wide invalidation.
Args:
tenant_id: The tenant ID
cache_key: The cache key to add to the index
"""
if not tenant_id:
return
try:
index_key = f"tenant_tokens:{tenant_id}"
redis_client.sadd(index_key, cache_key)
# Set TTL on the index itself (slightly longer than cache TTL)
redis_client.expire(index_key, CACHE_TTL_SECONDS + 60)
except Exception as e:
# Don't fail if index update fails
logger.warning("Failed to update tenant index: %s", e)
@staticmethod
def _remove_from_tenant_index(tenant_id: str | None, cache_key: str) -> None:
"""
Remove cache key from tenant index.
Args:
tenant_id: The tenant ID
cache_key: The cache key to remove from the index
"""
if not tenant_id:
return
try:
index_key = f"tenant_tokens:{tenant_id}"
redis_client.srem(index_key, cache_key)
except Exception as e:
# Don't fail if index update fails
logger.warning("Failed to remove from tenant index: %s", e)
@staticmethod
@redis_fallback(default_return=False)
def set(token: str, scope: str | None, api_token: Any | None, ttl: int = CACHE_TTL_SECONDS) -> bool:
"""
Set API token in cache.
Args:
token: The API token string
scope: The token type/scope
api_token: ApiToken instance to cache (None for non-existent tokens)
ttl: Time to live in seconds
Returns:
True if successful, False otherwise
"""
cache_key = ApiTokenCache._make_cache_key(token, scope)
if api_token is None:
# Cache null value to prevent cache penetration
cached_value = b"null"
ttl = CACHE_NULL_TTL_SECONDS
else:
cached_value = ApiTokenCache._serialize_token(api_token)
try:
redis_client.setex(cache_key, ttl, cached_value)
# Add to tenant index for efficient tenant-wide invalidation
if api_token is not None and hasattr(api_token, "tenant_id"):
ApiTokenCache._add_to_tenant_index(api_token.tenant_id, cache_key)
logger.debug("Cached token with key: %s, ttl: %ss", cache_key, ttl)
return True
except Exception as e:
logger.warning("Failed to cache token: %s", e)
return False
@staticmethod
@redis_fallback(default_return=False)
def delete(token: str, scope: str | None = None) -> bool:
"""
Delete API token from cache.
Args:
token: The API token string
scope: The token type/scope (None to delete all scopes)
Returns:
True if successful, False otherwise
"""
if scope is None:
# Delete all possible scopes for this token
# This is a safer approach when scope is unknown
pattern = f"{CACHE_KEY_PREFIX}:*:{token}"
try:
keys_to_delete = list(redis_client.scan_iter(match=pattern))
if keys_to_delete:
redis_client.delete(*keys_to_delete)
logger.info("Deleted %d cache entries for token", len(keys_to_delete))
return True
except Exception as e:
logger.warning("Failed to delete token cache with pattern: %s", e)
return False
else:
cache_key = ApiTokenCache._make_cache_key(token, scope)
try:
# Try to get tenant_id before deleting (for index cleanup)
tenant_id = None
try:
cached_data = redis_client.get(cache_key)
if cached_data and cached_data != b"null":
cached_token = ApiTokenCache._deserialize_token(cached_data)
if cached_token:
tenant_id = cached_token.tenant_id
except Exception as e:
# If we can't get tenant_id, just delete the key without index cleanup
logger.debug("Failed to get tenant_id for cache cleanup: %s", e)
# Delete the cache key
redis_client.delete(cache_key)
# Remove from tenant index
if tenant_id:
ApiTokenCache._remove_from_tenant_index(tenant_id, cache_key)
logger.info("Deleted cache for key: %s", cache_key)
return True
except Exception as e:
logger.warning("Failed to delete token cache: %s", e)
return False

View File

@ -0,0 +1,102 @@
"""
Scheduled task to batch-update API token last_used_at timestamps.
Instead of updating the database on every request, token usage is recorded
in Redis as lightweight SET keys (api_token_active:{scope}:{token}).
This task runs periodically (default every 30 minutes) to flush those
records into the database in a single batch operation.
"""
import logging
import time
import click
from sqlalchemy import update
from sqlalchemy.orm import Session
import app
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.datetime_utils import naive_utc_now
from models.model import ApiToken
logger = logging.getLogger(__name__)
ACTIVE_TOKEN_KEY_PREFIX = "api_token_active:"
@app.celery.task(queue="api_token_update")
def batch_update_api_token_last_used():
"""
Batch update last_used_at for all recently active API tokens.
Scans Redis for api_token_active:* keys, parses the token and scope
from each key, and performs a batch database update.
"""
click.echo(click.style("batch_update_api_token_last_used: start.", fg="green"))
start_at = time.perf_counter()
updated_count = 0
scanned_count = 0
current_time = naive_utc_now()
try:
# Collect all active token keys
keys_to_process: list[str] = []
for key in redis_client.scan_iter(match=f"{ACTIVE_TOKEN_KEY_PREFIX}*", count=200):
if isinstance(key, bytes):
key = key.decode("utf-8")
keys_to_process.append(key)
scanned_count += 1
if not keys_to_process:
click.echo(click.style("batch_update_api_token_last_used: no active tokens found.", fg="yellow"))
return
# Parse token info from keys: api_token_active:{scope}:{token}
token_scope_pairs: list[tuple[str, str | None]] = []
for key in keys_to_process:
# Strip prefix
suffix = key[len(ACTIVE_TOKEN_KEY_PREFIX) :]
# Split into scope:token (scope may be "None")
parts = suffix.split(":", 1)
if len(parts) == 2:
scope_str, token = parts
scope = None if scope_str == "None" else scope_str
token_scope_pairs.append((token, scope))
# Batch update in database
with Session(db.engine, expire_on_commit=False) as session:
for token, scope in token_scope_pairs:
stmt = (
update(ApiToken)
.where(
ApiToken.token == token,
ApiToken.type == scope,
(ApiToken.last_used_at.is_(None) | (ApiToken.last_used_at < current_time)),
)
.values(last_used_at=current_time)
)
result = session.execute(stmt)
rowcount = getattr(result, "rowcount", 0)
if rowcount > 0:
updated_count += 1
if updated_count > 0:
session.commit()
# Delete processed keys from Redis
if keys_to_process:
redis_client.delete(*[k.encode("utf-8") if isinstance(k, str) else k for k in keys_to_process])
except Exception:
logger.exception("batch_update_api_token_last_used failed")
elapsed = time.perf_counter() - start_at
click.echo(
click.style(
f"batch_update_api_token_last_used: done. "
f"scanned={scanned_count}, updated={updated_count}, elapsed={elapsed:.2f}s",
fg="green",
)
)

View File

@ -14,6 +14,7 @@ from sqlalchemy.orm import sessionmaker
from configs import dify_config
from core.db.session_factory import session_factory
from extensions.ext_database import db
from libs.api_token_cache import ApiTokenCache
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
from models import (
ApiToken,
@ -134,6 +135,12 @@ def _delete_app_mcp_servers(tenant_id: str, app_id: str):
def _delete_app_api_tokens(tenant_id: str, app_id: str):
def del_api_token(session, api_token_id: str):
# Fetch token details for cache invalidation
token_obj = session.query(ApiToken).where(ApiToken.id == api_token_id).first()
if token_obj:
# Invalidate cache before deletion
ApiTokenCache.delete(token_obj.token, token_obj.type)
session.query(ApiToken).where(ApiToken.id == api_token_id).delete(synchronize_session=False)
_delete_records(

View File

@ -0,0 +1,386 @@
"""
Integration tests for API Token Cache with Redis.
These tests require:
- Redis server running
- Test database configured
"""
import time
from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
from extensions.ext_redis import redis_client
from libs.api_token_cache import ApiTokenCache, CachedApiToken
from models.model import ApiToken
class TestApiTokenCacheRedisIntegration:
"""Integration tests with real Redis."""
def setup_method(self):
"""Setup test fixtures and clean Redis."""
self.test_token = "test-integration-token-123"
self.test_scope = "app"
self.cache_key = f"api_token:{self.test_scope}:{self.test_token}"
# Clean up any existing test data
self._cleanup()
def teardown_method(self):
"""Cleanup test data from Redis."""
self._cleanup()
def _cleanup(self):
"""Remove test data from Redis."""
try:
redis_client.delete(self.cache_key)
redis_client.delete("tenant_tokens:test-tenant-id")
redis_client.delete(f"api_token_active:{self.test_scope}:{self.test_token}")
except Exception:
pass # Ignore cleanup errors
def test_cache_set_and_get_with_real_redis(self):
"""Test cache set and get operations with real Redis."""
from unittest.mock import MagicMock
mock_token = MagicMock()
mock_token.id = "test-id-123"
mock_token.app_id = "test-app-456"
mock_token.tenant_id = "test-tenant-789"
mock_token.type = "app"
mock_token.token = self.test_token
mock_token.last_used_at = datetime.now()
mock_token.created_at = datetime.now() - timedelta(days=30)
# Set in cache
result = ApiTokenCache.set(self.test_token, self.test_scope, mock_token)
assert result is True
# Verify in Redis
cached_data = redis_client.get(self.cache_key)
assert cached_data is not None
# Get from cache
cached_token = ApiTokenCache.get(self.test_token, self.test_scope)
assert cached_token is not None
assert isinstance(cached_token, CachedApiToken)
assert cached_token.id == "test-id-123"
assert cached_token.app_id == "test-app-456"
assert cached_token.tenant_id == "test-tenant-789"
assert cached_token.type == "app"
assert cached_token.token == self.test_token
def test_cache_ttl_with_real_redis(self):
"""Test cache TTL is set correctly."""
from unittest.mock import MagicMock
mock_token = MagicMock()
mock_token.id = "test-id"
mock_token.app_id = "test-app"
mock_token.tenant_id = "test-tenant"
mock_token.type = "app"
mock_token.token = self.test_token
mock_token.last_used_at = None
mock_token.created_at = datetime.now()
ApiTokenCache.set(self.test_token, self.test_scope, mock_token)
ttl = redis_client.ttl(self.cache_key)
assert 595 <= ttl <= 600 # Should be around 600 seconds (10 minutes)
def test_cache_null_value_for_invalid_token(self):
"""Test caching null value for invalid tokens."""
result = ApiTokenCache.set(self.test_token, self.test_scope, None)
assert result is True
cached_data = redis_client.get(self.cache_key)
assert cached_data == b"null"
cached_token = ApiTokenCache.get(self.test_token, self.test_scope)
assert cached_token is None
ttl = redis_client.ttl(self.cache_key)
assert 55 <= ttl <= 60
def test_cache_delete_with_real_redis(self):
"""Test cache deletion with real Redis."""
from unittest.mock import MagicMock
mock_token = MagicMock()
mock_token.id = "test-id"
mock_token.app_id = "test-app"
mock_token.tenant_id = "test-tenant"
mock_token.type = "app"
mock_token.token = self.test_token
mock_token.last_used_at = None
mock_token.created_at = datetime.now()
ApiTokenCache.set(self.test_token, self.test_scope, mock_token)
assert redis_client.exists(self.cache_key) == 1
result = ApiTokenCache.delete(self.test_token, self.test_scope)
assert result is True
assert redis_client.exists(self.cache_key) == 0
def test_tenant_index_creation(self):
"""Test tenant index is created when caching token."""
from unittest.mock import MagicMock
tenant_id = "test-tenant-id"
mock_token = MagicMock()
mock_token.id = "test-id"
mock_token.app_id = "test-app"
mock_token.tenant_id = tenant_id
mock_token.type = "app"
mock_token.token = self.test_token
mock_token.last_used_at = None
mock_token.created_at = datetime.now()
ApiTokenCache.set(self.test_token, self.test_scope, mock_token)
index_key = f"tenant_tokens:{tenant_id}"
assert redis_client.exists(index_key) == 1
members = redis_client.smembers(index_key)
cache_keys = [m.decode("utf-8") if isinstance(m, bytes) else m for m in members]
assert self.cache_key in cache_keys
def test_invalidate_by_tenant_via_index(self):
"""Test tenant-wide cache invalidation using index (fast path)."""
from unittest.mock import MagicMock
tenant_id = "test-tenant-id"
for i in range(3):
token_value = f"test-token-{i}"
mock_token = MagicMock()
mock_token.id = f"test-id-{i}"
mock_token.app_id = "test-app"
mock_token.tenant_id = tenant_id
mock_token.type = "app"
mock_token.token = token_value
mock_token.last_used_at = None
mock_token.created_at = datetime.now()
ApiTokenCache.set(token_value, "app", mock_token)
for i in range(3):
key = f"api_token:app:test-token-{i}"
assert redis_client.exists(key) == 1
result = ApiTokenCache.invalidate_by_tenant(tenant_id)
assert result is True
for i in range(3):
key = f"api_token:app:test-token-{i}"
assert redis_client.exists(key) == 0
assert redis_client.exists(f"tenant_tokens:{tenant_id}") == 0
def test_concurrent_cache_access(self):
"""Test concurrent cache access doesn't cause issues."""
import concurrent.futures
from unittest.mock import MagicMock
mock_token = MagicMock()
mock_token.id = "test-id"
mock_token.app_id = "test-app"
mock_token.tenant_id = "test-tenant"
mock_token.type = "app"
mock_token.token = self.test_token
mock_token.last_used_at = None
mock_token.created_at = datetime.now()
ApiTokenCache.set(self.test_token, self.test_scope, mock_token)
def get_from_cache():
return ApiTokenCache.get(self.test_token, self.test_scope)
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(get_from_cache) for _ in range(50)]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
assert len(results) == 50
assert all(r is not None for r in results)
assert all(isinstance(r, CachedApiToken) for r in results)
class TestTokenUsageRecording:
"""Tests for recording token usage in Redis (batch update approach)."""
def setup_method(self):
self.test_token = "test-usage-token"
self.test_scope = "app"
self.active_key = f"api_token_active:{self.test_scope}:{self.test_token}"
def teardown_method(self):
try:
redis_client.delete(self.active_key)
except Exception:
pass
def test_record_token_usage_sets_redis_key(self):
"""Test that _record_token_usage writes an active key to Redis."""
from controllers.service_api.wraps import _record_token_usage
_record_token_usage(self.test_token, self.test_scope)
# Key should exist
assert redis_client.exists(self.active_key) == 1
# Value should be an ISO timestamp
value = redis_client.get(self.active_key)
if isinstance(value, bytes):
value = value.decode("utf-8")
datetime.fromisoformat(value) # Should not raise
def test_record_token_usage_has_ttl(self):
"""Test that active keys have a TTL as safety net."""
from controllers.service_api.wraps import _record_token_usage
_record_token_usage(self.test_token, self.test_scope)
ttl = redis_client.ttl(self.active_key)
assert 3595 <= ttl <= 3600 # ~1 hour
def test_record_token_usage_overwrites(self):
"""Test that repeated calls overwrite the same key (no accumulation)."""
from controllers.service_api.wraps import _record_token_usage
_record_token_usage(self.test_token, self.test_scope)
first_value = redis_client.get(self.active_key)
time.sleep(0.01) # Tiny delay so timestamp differs
_record_token_usage(self.test_token, self.test_scope)
second_value = redis_client.get(self.active_key)
# Key count should still be 1 (overwritten, not accumulated)
assert redis_client.exists(self.active_key) == 1
class TestEndToEndCacheFlow:
"""End-to-end integration test for complete cache flow."""
@pytest.mark.usefixtures("db_session")
def test_complete_flow_cache_miss_then_hit(self, db_session):
"""
Test complete flow:
1. First request (cache miss) -> query DB -> cache result
2. Second request (cache hit) -> return from cache
3. Verify Redis state
"""
test_token_value = "test-e2e-token"
test_scope = "app"
test_token = ApiToken()
test_token.id = "test-e2e-id"
test_token.token = test_token_value
test_token.type = test_scope
test_token.app_id = "test-app"
test_token.tenant_id = "test-tenant"
test_token.last_used_at = None
test_token.created_at = datetime.now()
db_session.add(test_token)
db_session.commit()
try:
# Step 1: Cache miss - set token in cache
ApiTokenCache.set(test_token_value, test_scope, test_token)
cache_key = f"api_token:{test_scope}:{test_token_value}"
assert redis_client.exists(cache_key) == 1
# Step 2: Cache hit - get from cache
cached_token = ApiTokenCache.get(test_token_value, test_scope)
assert cached_token is not None
assert cached_token.id == test_token.id
assert cached_token.token == test_token_value
# Step 3: Verify tenant index
index_key = f"tenant_tokens:{test_token.tenant_id}"
assert redis_client.exists(index_key) == 1
assert cache_key.encode() in redis_client.smembers(index_key)
# Step 4: Delete and verify cleanup
ApiTokenCache.delete(test_token_value, test_scope)
assert redis_client.exists(cache_key) == 0
assert cache_key.encode() not in redis_client.smembers(index_key)
finally:
db_session.delete(test_token)
db_session.commit()
redis_client.delete(f"api_token:{test_scope}:{test_token_value}")
redis_client.delete(f"tenant_tokens:{test_token.tenant_id}")
def test_high_concurrency_simulation(self):
"""Simulate high concurrency access to cache."""
import concurrent.futures
from unittest.mock import MagicMock
test_token_value = "test-concurrent-token"
test_scope = "app"
mock_token = MagicMock()
mock_token.id = "concurrent-id"
mock_token.app_id = "test-app"
mock_token.tenant_id = "test-tenant"
mock_token.type = test_scope
mock_token.token = test_token_value
mock_token.last_used_at = datetime.now()
mock_token.created_at = datetime.now()
ApiTokenCache.set(test_token_value, test_scope, mock_token)
try:
def read_cache():
return ApiTokenCache.get(test_token_value, test_scope)
start_time = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
futures = [executor.submit(read_cache) for _ in range(100)]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
elapsed = time.time() - start_time
assert len(results) == 100
assert all(r is not None for r in results)
assert elapsed < 1.0, f"Too slow: {elapsed}s for 100 cache reads"
finally:
ApiTokenCache.delete(test_token_value, test_scope)
redis_client.delete(f"tenant_tokens:{mock_token.tenant_id}")
class TestRedisFailover:
"""Test behavior when Redis is unavailable."""
@patch("libs.api_token_cache.redis_client")
def test_graceful_degradation_when_redis_fails(self, mock_redis):
"""Test system degrades gracefully when Redis is unavailable."""
from redis import RedisError
mock_redis.get.side_effect = RedisError("Connection failed")
mock_redis.setex.side_effect = RedisError("Connection failed")
result_get = ApiTokenCache.get("test-token", "app")
assert result_get is None
result_set = ApiTokenCache.set("test-token", "app", None)
assert result_set is False
if __name__ == "__main__":
pytest.main(
[
__file__,
"-v",
"-s",
"--tb=short",
]
)

View File

@ -132,6 +132,8 @@ class TestCelerySSLConfiguration:
mock_config.WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK = 0
mock_config.ENABLE_TRIGGER_PROVIDER_REFRESH_TASK = False
mock_config.TRIGGER_PROVIDER_REFRESH_INTERVAL = 15
mock_config.ENABLE_API_TOKEN_LAST_USED_UPDATE_TASK = False
mock_config.API_TOKEN_LAST_USED_UPDATE_INTERVAL = 30
with patch("extensions.ext_celery.dify_config", mock_config):
from dify_app import DifyApp

View File

@ -0,0 +1,256 @@
"""
Unit tests for API Token Cache module.
"""
import json
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from libs.api_token_cache import (
CACHE_KEY_PREFIX,
CACHE_NULL_TTL_SECONDS,
CACHE_TTL_SECONDS,
ApiTokenCache,
CachedApiToken,
)
class TestApiTokenCache:
"""Test cases for ApiTokenCache class."""
def setup_method(self):
"""Setup test fixtures."""
self.mock_token = MagicMock()
self.mock_token.id = "test-token-id-123"
self.mock_token.app_id = "test-app-id-456"
self.mock_token.tenant_id = "test-tenant-id-789"
self.mock_token.type = "app"
self.mock_token.token = "test-token-value-abc"
self.mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
self.mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
def test_make_cache_key(self):
"""Test cache key generation."""
# Test with scope
key = ApiTokenCache._make_cache_key("my-token", "app")
assert key == f"{CACHE_KEY_PREFIX}:app:my-token"
# Test without scope
key = ApiTokenCache._make_cache_key("my-token", None)
assert key == f"{CACHE_KEY_PREFIX}:any:my-token"
def test_serialize_token(self):
"""Test token serialization."""
serialized = ApiTokenCache._serialize_token(self.mock_token)
data = json.loads(serialized)
assert data["id"] == "test-token-id-123"
assert data["app_id"] == "test-app-id-456"
assert data["tenant_id"] == "test-tenant-id-789"
assert data["type"] == "app"
assert data["token"] == "test-token-value-abc"
assert data["last_used_at"] == "2026-02-03T10:00:00"
assert data["created_at"] == "2026-01-01T00:00:00"
def test_serialize_token_with_nulls(self):
"""Test token serialization with None values."""
mock_token = MagicMock()
mock_token.id = "test-id"
mock_token.app_id = None
mock_token.tenant_id = None
mock_token.type = "dataset"
mock_token.token = "test-token"
mock_token.last_used_at = None
mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
serialized = ApiTokenCache._serialize_token(mock_token)
data = json.loads(serialized)
assert data["app_id"] is None
assert data["tenant_id"] is None
assert data["last_used_at"] is None
def test_deserialize_token(self):
"""Test token deserialization."""
cached_data = json.dumps(
{
"id": "test-id",
"app_id": "test-app",
"tenant_id": "test-tenant",
"type": "app",
"token": "test-token",
"last_used_at": "2026-02-03T10:00:00",
"created_at": "2026-01-01T00:00:00",
}
)
result = ApiTokenCache._deserialize_token(cached_data)
assert isinstance(result, CachedApiToken)
assert result.id == "test-id"
assert result.app_id == "test-app"
assert result.tenant_id == "test-tenant"
assert result.type == "app"
assert result.token == "test-token"
assert result.last_used_at == datetime(2026, 2, 3, 10, 0, 0)
assert result.created_at == datetime(2026, 1, 1, 0, 0, 0)
def test_deserialize_null_token(self):
"""Test deserialization of null token (cached miss)."""
result = ApiTokenCache._deserialize_token("null")
assert result is None
def test_deserialize_invalid_json(self):
"""Test deserialization with invalid JSON."""
result = ApiTokenCache._deserialize_token("invalid-json{")
assert result is None
@patch("libs.api_token_cache.redis_client")
def test_get_cache_hit(self, mock_redis):
"""Test cache hit scenario."""
cached_data = json.dumps(
{
"id": "test-id",
"app_id": "test-app",
"tenant_id": "test-tenant",
"type": "app",
"token": "test-token",
"last_used_at": "2026-02-03T10:00:00",
"created_at": "2026-01-01T00:00:00",
}
).encode("utf-8")
mock_redis.get.return_value = cached_data
result = ApiTokenCache.get("test-token", "app")
assert result is not None
assert isinstance(result, CachedApiToken)
assert result.app_id == "test-app"
mock_redis.get.assert_called_once_with(f"{CACHE_KEY_PREFIX}:app:test-token")
@patch("libs.api_token_cache.redis_client")
def test_get_cache_miss(self, mock_redis):
"""Test cache miss scenario."""
mock_redis.get.return_value = None
result = ApiTokenCache.get("test-token", "app")
assert result is None
mock_redis.get.assert_called_once()
@patch("libs.api_token_cache.redis_client")
def test_set_valid_token(self, mock_redis):
"""Test setting a valid token in cache."""
result = ApiTokenCache.set("test-token", "app", self.mock_token)
assert result is True
mock_redis.setex.assert_called_once()
args = mock_redis.setex.call_args[0]
assert args[0] == f"{CACHE_KEY_PREFIX}:app:test-token"
assert args[1] == CACHE_TTL_SECONDS
@patch("libs.api_token_cache.redis_client")
def test_set_null_token(self, mock_redis):
"""Test setting a null token (cache penetration prevention)."""
result = ApiTokenCache.set("invalid-token", "app", None)
assert result is True
mock_redis.setex.assert_called_once()
args = mock_redis.setex.call_args[0]
assert args[0] == f"{CACHE_KEY_PREFIX}:app:invalid-token"
assert args[1] == CACHE_NULL_TTL_SECONDS
assert args[2] == b"null"
@patch("libs.api_token_cache.redis_client")
def test_delete_with_scope(self, mock_redis):
"""Test deleting token cache with specific scope."""
result = ApiTokenCache.delete("test-token", "app")
assert result is True
mock_redis.delete.assert_called_once_with(f"{CACHE_KEY_PREFIX}:app:test-token")
@patch("libs.api_token_cache.redis_client")
def test_delete_without_scope(self, mock_redis):
"""Test deleting token cache without scope (delete all)."""
# Mock scan_iter to return an iterator of keys
mock_redis.scan_iter.return_value = iter(
[
b"api_token:app:test-token",
b"api_token:dataset:test-token",
]
)
result = ApiTokenCache.delete("test-token", None)
assert result is True
# Verify scan_iter was called with the correct pattern
mock_redis.scan_iter.assert_called_once()
call_args = mock_redis.scan_iter.call_args
assert call_args[1]["match"] == f"{CACHE_KEY_PREFIX}:*:test-token"
# Verify delete was called with all matched keys
mock_redis.delete.assert_called_once_with(
b"api_token:app:test-token",
b"api_token:dataset:test-token",
)
@patch("libs.api_token_cache.redis_client")
def test_redis_fallback_on_exception(self, mock_redis):
"""Test Redis fallback when Redis is unavailable."""
from redis import RedisError
mock_redis.get.side_effect = RedisError("Connection failed")
result = ApiTokenCache.get("test-token", "app")
# Should return None (fallback) instead of raising exception
assert result is None
class TestApiTokenCacheIntegration:
"""Integration test scenarios."""
@patch("libs.api_token_cache.redis_client")
def test_full_cache_lifecycle(self, mock_redis):
"""Test complete cache lifecycle: set -> get -> delete."""
# Setup mock token
mock_token = MagicMock()
mock_token.id = "id-123"
mock_token.app_id = "app-456"
mock_token.tenant_id = "tenant-789"
mock_token.type = "app"
mock_token.token = "token-abc"
mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
# 1. Set token in cache
ApiTokenCache.set("token-abc", "app", mock_token)
assert mock_redis.setex.called
# 2. Simulate cache hit
cached_data = ApiTokenCache._serialize_token(mock_token)
mock_redis.get.return_value = cached_data # bytes from model_dump_json().encode()
retrieved = ApiTokenCache.get("token-abc", "app")
assert retrieved is not None
assert isinstance(retrieved, CachedApiToken)
# 3. Delete from cache
ApiTokenCache.delete("token-abc", "app")
assert mock_redis.delete.called
@patch("libs.api_token_cache.redis_client")
def test_cache_penetration_prevention(self, mock_redis):
"""Test that non-existent tokens are cached as null."""
# Set null token (cache miss)
ApiTokenCache.set("non-existent-token", "app", None)
args = mock_redis.setex.call_args[0]
assert args[2] == b"null"
assert args[1] == CACHE_NULL_TTL_SECONDS # Shorter TTL for null values
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@ -1,27 +1,19 @@
'use client'
import type { FileUpload } from '@/app/components/base/features/types'
import type { App } from '@/types/app'
import * as React from 'react'
import { useMemo, useRef } from 'react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import AppInputsForm from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form'
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { useAppDetail } from '@/service/use-apps'
import { useFileUploadConfig } from '@/service/use-common'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum, Resolution } from '@/types/app'
import { useAppInputsFormSchema } from '@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema'
import { cn } from '@/utils/classnames'
type Props = {
value?: {
app_id: string
inputs: Record<string, any>
inputs: Record<string, unknown>
}
appDetail: App
onFormChange: (value: Record<string, any>) => void
onFormChange: (value: Record<string, unknown>) => void
}
const AppInputsPanel = ({
@ -30,155 +22,33 @@ const AppInputsPanel = ({
onFormChange,
}: Props) => {
const { t } = useTranslation()
const inputsRef = useRef<any>(value?.inputs || {})
const isBasicApp = appDetail.mode !== AppModeEnum.ADVANCED_CHAT && appDetail.mode !== AppModeEnum.WORKFLOW
const { data: fileUploadConfig } = useFileUploadConfig()
const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id)
const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(isBasicApp ? '' : appDetail.id)
const isLoading = isAppLoading || isWorkflowLoading
const inputsRef = useRef<Record<string, unknown>>(value?.inputs || {})
const basicAppFileConfig = useMemo(() => {
let fileConfig: FileUpload
if (isBasicApp)
fileConfig = currentApp?.model_config?.file_upload as FileUpload
else
fileConfig = currentWorkflow?.features?.file_upload as FileUpload
return {
image: {
detail: fileConfig?.image?.detail || Resolution.high,
enabled: !!fileConfig?.image?.enabled,
number_limits: fileConfig?.image?.number_limits || 3,
transfer_methods: fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled),
allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: fileConfig?.allowed_file_extensions || [...FILE_EXTS[SupportUploadFileTypes.image]].map(ext => `.${ext}`),
allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods || fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3,
}
}, [currentApp?.model_config?.file_upload, currentWorkflow?.features?.file_upload, isBasicApp])
const { inputFormSchema, isLoading } = useAppInputsFormSchema({ appDetail })
const inputFormSchema = useMemo(() => {
if (!currentApp)
return []
let inputFormSchema = []
if (isBasicApp) {
inputFormSchema = currentApp.model_config?.user_input_form?.filter((item: any) => !item.external_data_tool).map((item: any) => {
if (item.paragraph) {
return {
...item.paragraph,
type: 'paragraph',
required: false,
}
}
if (item.number) {
return {
...item.number,
type: 'number',
required: false,
}
}
if (item.checkbox) {
return {
...item.checkbox,
type: 'checkbox',
required: false,
}
}
if (item.select) {
return {
...item.select,
type: 'select',
required: false,
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
required: false,
fileUploadConfig,
}
}
if (item.file) {
return {
...item.file,
type: 'file',
required: false,
fileUploadConfig,
}
}
if (item.json_object) {
return {
...item.json_object,
type: 'json_object',
}
}
return {
...item['text-input'],
type: 'text-input',
required: false,
}
}) || []
}
else {
const startNode = currentWorkflow?.graph?.nodes.find(node => node.data.type === BlockEnum.Start) as any
inputFormSchema = startNode?.data.variables.map((variable: any) => {
if (variable.type === InputVarType.multiFiles) {
return {
...variable,
required: false,
fileUploadConfig,
}
}
if (variable.type === InputVarType.singleFile) {
return {
...variable,
required: false,
fileUploadConfig,
}
}
return {
...variable,
required: false,
}
}) || []
}
if ((currentApp.mode === AppModeEnum.COMPLETION || currentApp.mode === AppModeEnum.WORKFLOW) && basicAppFileConfig.enabled) {
inputFormSchema.push({
label: 'Image Upload',
variable: '#image#',
type: InputVarType.singleFile,
required: false,
...basicAppFileConfig,
fileUploadConfig,
})
}
return inputFormSchema || []
}, [basicAppFileConfig, currentApp, currentWorkflow, fileUploadConfig, isBasicApp])
const handleFormChange = (value: Record<string, any>) => {
inputsRef.current = value
onFormChange(value)
const handleFormChange = (newValue: Record<string, unknown>) => {
inputsRef.current = newValue
onFormChange(newValue)
}
const hasInputs = inputFormSchema.length > 0
return (
<div className={cn('flex max-h-[240px] flex-col rounded-b-2xl border-t border-divider-subtle pb-4')}>
{isLoading && <div className="pt-3"><Loading type="app" /></div>}
{!isLoading && (
<div className="system-sm-semibold mb-2 mt-3 flex h-6 shrink-0 items-center px-4 text-text-secondary">{t('appSelector.params', { ns: 'app' })}</div>
)}
{!isLoading && !inputFormSchema.length && (
<div className="flex h-16 flex-col items-center justify-center">
<div className="system-sm-regular text-text-tertiary">{t('appSelector.noParams', { ns: 'app' })}</div>
<div className="system-sm-semibold mb-2 mt-3 flex h-6 shrink-0 items-center px-4 text-text-secondary">
{t('appSelector.params', { ns: 'app' })}
</div>
)}
{!isLoading && !!inputFormSchema.length && (
{!isLoading && !hasInputs && (
<div className="flex h-16 flex-col items-center justify-center">
<div className="system-sm-regular text-text-tertiary">
{t('appSelector.noParams', { ns: 'app' })}
</div>
</div>
)}
{!isLoading && hasInputs && (
<div className="grow overflow-y-auto">
<AppInputsForm
inputs={value?.inputs || {}}

View File

@ -0,0 +1,211 @@
'use client'
import type { FileUpload } from '@/app/components/base/features/types'
import type { FileUploadConfigResponse } from '@/models/common'
import type { App } from '@/types/app'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { useMemo } from 'react'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { useAppDetail } from '@/service/use-apps'
import { useFileUploadConfig } from '@/service/use-common'
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum, Resolution } from '@/types/app'
const BASIC_INPUT_TYPE_MAP: Record<string, string> = {
'paragraph': 'paragraph',
'number': 'number',
'checkbox': 'checkbox',
'select': 'select',
'file-list': 'file-list',
'file': 'file',
'json_object': 'json_object',
}
const FILE_INPUT_TYPES = new Set(['file-list', 'file'])
const WORKFLOW_FILE_VAR_TYPES = new Set([InputVarType.multiFiles, InputVarType.singleFile])
type InputSchemaItem = {
label?: string
variable?: string
type: string
required: boolean
fileUploadConfig?: FileUploadConfigResponse
[key: string]: unknown
}
function isBasicAppMode(mode: string): boolean {
return mode !== AppModeEnum.ADVANCED_CHAT && mode !== AppModeEnum.WORKFLOW
}
function supportsImageUpload(mode: string): boolean {
return mode === AppModeEnum.COMPLETION || mode === AppModeEnum.WORKFLOW
}
function buildFileConfig(fileConfig: FileUpload | undefined) {
return {
image: {
detail: fileConfig?.image?.detail || Resolution.high,
enabled: !!fileConfig?.image?.enabled,
number_limits: fileConfig?.image?.number_limits || 3,
transfer_methods: fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled),
allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: fileConfig?.allowed_file_extensions
|| [...FILE_EXTS[SupportUploadFileTypes.image]].map(ext => `.${ext}`),
allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods
|| fileConfig?.image?.transfer_methods
|| ['local_file', 'remote_url'],
number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3,
}
}
function mapBasicAppInputItem(
item: Record<string, unknown>,
fileUploadConfig?: FileUploadConfigResponse,
): InputSchemaItem | null {
for (const [key, type] of Object.entries(BASIC_INPUT_TYPE_MAP)) {
if (!item[key])
continue
const inputData = item[key] as Record<string, unknown>
const needsFileConfig = FILE_INPUT_TYPES.has(key)
return {
...inputData,
type,
required: false,
...(needsFileConfig && { fileUploadConfig }),
}
}
const textInput = item['text-input'] as Record<string, unknown> | undefined
if (!textInput)
return null
return {
...textInput,
type: 'text-input',
required: false,
}
}
function mapWorkflowVariable(
variable: Record<string, unknown>,
fileUploadConfig?: FileUploadConfigResponse,
): InputSchemaItem {
const needsFileConfig = WORKFLOW_FILE_VAR_TYPES.has(variable.type as InputVarType)
return {
...variable,
type: variable.type as string,
required: false,
...(needsFileConfig && { fileUploadConfig }),
}
}
function createImageUploadSchema(
basicFileConfig: ReturnType<typeof buildFileConfig>,
fileUploadConfig?: FileUploadConfigResponse,
): InputSchemaItem {
return {
label: 'Image Upload',
variable: '#image#',
type: InputVarType.singleFile,
required: false,
...basicFileConfig,
fileUploadConfig,
}
}
function buildBasicAppSchema(
currentApp: App,
fileUploadConfig?: FileUploadConfigResponse,
): InputSchemaItem[] {
const userInputForm = currentApp.model_config?.user_input_form as Array<Record<string, unknown>> | undefined
if (!userInputForm)
return []
return userInputForm
.filter((item: Record<string, unknown>) => !item.external_data_tool)
.map((item: Record<string, unknown>) => mapBasicAppInputItem(item, fileUploadConfig))
.filter((item): item is InputSchemaItem => item !== null)
}
function buildWorkflowSchema(
workflow: FetchWorkflowDraftResponse,
fileUploadConfig?: FileUploadConfigResponse,
): InputSchemaItem[] {
const startNode = workflow.graph?.nodes.find(
node => node.data.type === BlockEnum.Start,
) as { data: { variables: Array<Record<string, unknown>> } } | undefined
if (!startNode?.data.variables)
return []
return startNode.data.variables.map(
variable => mapWorkflowVariable(variable, fileUploadConfig),
)
}
type UseAppInputsFormSchemaParams = {
appDetail: App
}
type UseAppInputsFormSchemaResult = {
inputFormSchema: InputSchemaItem[]
isLoading: boolean
fileUploadConfig?: FileUploadConfigResponse
}
export function useAppInputsFormSchema({
appDetail,
}: UseAppInputsFormSchemaParams): UseAppInputsFormSchemaResult {
const isBasicApp = isBasicAppMode(appDetail.mode)
const { data: fileUploadConfig } = useFileUploadConfig()
const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id)
const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(
isBasicApp ? '' : appDetail.id,
)
const isLoading = isAppLoading || isWorkflowLoading
const inputFormSchema = useMemo(() => {
if (!currentApp)
return []
if (!isBasicApp && !currentWorkflow)
return []
// Build base schema based on app type
// Note: currentWorkflow is guaranteed to be defined here due to the early return above
const baseSchema = isBasicApp
? buildBasicAppSchema(currentApp, fileUploadConfig)
: buildWorkflowSchema(currentWorkflow!, fileUploadConfig)
if (!supportsImageUpload(currentApp.mode))
return baseSchema
const rawFileConfig = isBasicApp
? currentApp.model_config?.file_upload as FileUpload
: currentWorkflow?.features?.file_upload as FileUpload
const basicFileConfig = buildFileConfig(rawFileConfig)
if (!basicFileConfig.enabled)
return baseSchema
return [
...baseSchema,
createImageUploadSchema(basicFileConfig, fileUploadConfig),
]
}, [currentApp, currentWorkflow, fileUploadConfig, isBasicApp])
return {
inputFormSchema,
isLoading,
fileUploadConfig,
}
}

View File

@ -6,7 +6,6 @@ import Toast from '@/app/components/base/toast'
import { PluginSource } from '../types'
import DetailHeader from './detail-header'
// Use vi.hoisted for mock functions used in vi.mock factories
const {
mockSetShowUpdatePluginModal,
mockRefreshModelProviders,

View File

@ -1,416 +1,2 @@
import type { PluginDetail } from '../types'
import {
RiArrowLeftRightLine,
RiBugLine,
RiCloseLine,
RiHardDrive3Line,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { trackEvent } from '@/app/components/base/amplitude'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import { Github } from '@/app/components/base/icons/src/public/common'
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
import { API_PREFIX } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage, useLocale } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
import { uninstallPlugin } from '@/service/plugins'
import { useAllToolProviders, useInvalidateAllToolProviders } from '@/service/use-tools'
import { cn } from '@/utils/classnames'
import { getMarketplaceUrl } from '@/utils/var'
import { AutoUpdateLine } from '../../base/icons/src/vender/system'
import Verified from '../base/badges/verified'
import DeprecationNotice from '../base/deprecation-notice'
import Icon from '../card/base/card-icon'
import Description from '../card/base/description'
import OrgInfo from '../card/base/org-info'
import Title from '../card/base/title'
import { useGitHubReleases } from '../install-plugin/hooks'
import useReferenceSetting from '../plugin-page/use-reference-setting'
import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types'
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils'
import { PluginCategoryEnum, PluginSource } from '../types'
const i18nPrefix = 'action'
type Props = {
detail: PluginDetail
isReadmeView?: boolean
onHide?: () => void
onUpdate?: (isDelete?: boolean) => void
}
const DetailHeader = ({
detail,
isReadmeView = false,
onHide,
onUpdate,
}: Props) => {
const { t } = useTranslation()
const { userProfile: { timezone } } = useAppContext()
const { theme } = useTheme()
const locale = useGetLanguage()
const currentLocale = useLocale()
const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext()
const { refreshModelProviders } = useProviderContext()
const invalidateAllToolProviders = useInvalidateAllToolProviders()
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const {
id,
source,
tenant_id,
version,
latest_unique_identifier,
latest_version,
meta,
plugin_id,
status,
deprecated_reason,
alternative_plugin_id,
} = detail
const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
const isTool = category === PluginCategoryEnum.tool
const providerBriefInfo = tool?.identity
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
const { data: collectionList = [] } = useAllToolProviders(isTool)
const provider = useMemo(() => {
return collectionList.find(collection => collection.name === providerKey)
}, [collectionList, providerKey])
const isFromGitHub = source === PluginSource.github
const isFromMarketplace = source === PluginSource.marketplace
const [isShow, setIsShow] = useState(false)
const [targetVersion, setTargetVersion] = useState({
version: latest_version,
unique_identifier: latest_unique_identifier,
})
const hasNewVersion = useMemo(() => {
if (isFromMarketplace)
return !!latest_version && latest_version !== version
return false
}, [isFromMarketplace, latest_version, version])
const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon
const iconSrc = iconFileName
? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`)
: ''
const detailUrl = useMemo(() => {
if (isFromGitHub)
return `https://github.com/${meta!.repo}`
if (isFromMarketplace)
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: currentLocale, theme })
return ''
}, [author, isFromGitHub, isFromMarketplace, meta, name, theme])
const [isShowUpdateModal, {
setTrue: showUpdateModal,
setFalse: hideUpdateModal,
}] = useBoolean(false)
const { referenceSetting } = useReferenceSetting()
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
const isAutoUpgradeEnabled = useMemo(() => {
if (!enable_marketplace)
return false
if (!autoUpgradeInfo || !isFromMarketplace)
return false
if (autoUpgradeInfo.strategy_setting === 'disabled')
return false
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
return true
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
return true
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
return true
return false
}, [autoUpgradeInfo, plugin_id, isFromMarketplace])
const [isDowngrade, setIsDowngrade] = useState(false)
const handleUpdate = async (isDowngrade?: boolean) => {
if (isFromMarketplace) {
setIsDowngrade(!!isDowngrade)
showUpdateModal()
return
}
const owner = meta!.repo.split('/')[0] || author
const repo = meta!.repo.split('/')[1] || name
const fetchedReleases = await fetchReleases(owner, repo)
if (fetchedReleases.length === 0)
return
const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta!.version)
Toast.notify(toastProps)
if (needUpdate) {
setShowUpdatePluginModal({
onSaveCallback: () => {
onUpdate?.()
},
payload: {
type: PluginSource.github,
category: detail.declaration.category,
github: {
originalPackageInfo: {
id: detail.plugin_unique_identifier,
repo: meta!.repo,
version: meta!.version,
package: meta!.package,
releases: fetchedReleases,
},
},
},
})
}
}
const handleUpdatedFromMarketplace = () => {
onUpdate?.()
hideUpdateModal()
}
const [isShowPluginInfo, {
setTrue: showPluginInfo,
setFalse: hidePluginInfo,
}] = useBoolean(false)
const [isShowDeleteConfirm, {
setTrue: showDeleteConfirm,
setFalse: hideDeleteConfirm,
}] = useBoolean(false)
const [deleting, {
setTrue: showDeleting,
setFalse: hideDeleting,
}] = useBoolean(false)
const handleDelete = useCallback(async () => {
showDeleting()
const res = await uninstallPlugin(id)
hideDeleting()
if (res.success) {
hideDeleteConfirm()
onUpdate?.(true)
if (PluginCategoryEnum.model.includes(category))
refreshModelProviders()
if (PluginCategoryEnum.tool.includes(category))
invalidateAllToolProviders()
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
}
}, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders, plugin_id, name])
return (
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
<div className="flex">
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
<Icon src={iconSrc} />
</div>
<div className="ml-3 w-0 grow">
<div className="flex h-5 items-center">
<Title title={label[locale]} />
{verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
{!!version && (
<PluginVersionPicker
disabled={!isFromMarketplace || isReadmeView}
isShow={isShow}
onShowChange={setIsShow}
pluginID={plugin_id}
currentVersion={version}
onSelect={(state) => {
setTargetVersion(state)
handleUpdate(state.isDowngrade)
}}
trigger={(
<Badge
className={cn(
'mx-1',
isShow && 'bg-state-base-hover',
(isShow || isFromMarketplace) && 'hover:bg-state-base-hover',
)}
uppercase={false}
text={(
<>
<div>{isFromGitHub ? meta!.version : version}</div>
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
</>
)}
hasRedCornerMark={hasNewVersion}
/>
)}
/>
)}
{/* Auto update info */}
{isAutoUpgradeEnabled && !isReadmeView && (
<Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
{/* add a a div to fix tooltip hover not show problem */}
<div>
<Badge className="mr-1 cursor-pointer px-1">
<AutoUpdateLine className="size-3" />
</Badge>
</div>
</Tooltip>
)}
{(hasNewVersion || isFromGitHub) && (
<Button
variant="secondary-accent"
size="small"
className="!h-5"
onClick={() => {
if (isFromMarketplace) {
setTargetVersion({
version: latest_version,
unique_identifier: latest_unique_identifier,
})
}
handleUpdate()
}}
>
{t('detailPanel.operation.update', { ns: 'plugin' })}
</Button>
)}
</div>
<div className="mb-1 flex h-4 items-center justify-between">
<div className="mt-0.5 flex items-center">
<OrgInfo
packageNameClassName="w-auto"
orgName={author}
packageName={name?.includes('/') ? (name.split('/').pop() || '') : name}
/>
{!!source && (
<>
<div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div>
{source === PluginSource.marketplace && (
<Tooltip popupContent={t('detailPanel.categoryTip.marketplace', { ns: 'plugin' })}>
<div><BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" /></div>
</Tooltip>
)}
{source === PluginSource.github && (
<Tooltip popupContent={t('detailPanel.categoryTip.github', { ns: 'plugin' })}>
<div><Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" /></div>
</Tooltip>
)}
{source === PluginSource.local && (
<Tooltip popupContent={t('detailPanel.categoryTip.local', { ns: 'plugin' })}>
<div><RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" /></div>
</Tooltip>
)}
{source === PluginSource.debugging && (
<Tooltip popupContent={t('detailPanel.categoryTip.debugging', { ns: 'plugin' })}>
<div><RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" /></div>
</Tooltip>
)}
</>
)}
</div>
</div>
</div>
{!isReadmeView && (
<div className="flex gap-1">
<OperationDropdown
source={source}
onInfo={showPluginInfo}
onCheckVersion={handleUpdate}
onRemove={showDeleteConfirm}
detailUrl={detailUrl}
/>
<ActionButton onClick={onHide}>
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
)}
</div>
{isFromMarketplace && (
<DeprecationNotice
status={status}
deprecatedReason={deprecated_reason}
alternativePluginId={alternative_plugin_id}
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
className="mt-3"
/>
)}
{!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2}></Description>}
{
category === PluginCategoryEnum.tool && !isReadmeView && (
<PluginAuth
pluginPayload={{
provider: provider?.name || '',
category: AuthCategory.tool,
providerType: provider?.type || '',
detail,
}}
/>
)
}
{isShowPluginInfo && (
<PluginInfo
repository={isFromGitHub ? meta?.repo : ''}
release={version}
packageName={meta?.package || ''}
onHide={hidePluginInfo}
/>
)}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
content={(
<div>
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
<span className="system-md-semibold">{label[locale]}</span>
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
<br />
</div>
)}
onCancel={hideDeleteConfirm}
onConfirm={handleDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
{
isShowUpdateModal && (
<UpdateFromMarketplace
pluginId={plugin_id}
payload={{
category: detail.declaration.category,
originalPackageInfo: {
id: detail.plugin_unique_identifier,
payload: detail.declaration,
},
targetPackageInfo: {
id: targetVersion.unique_identifier,
version: targetVersion.version,
},
}}
onCancel={hideUpdateModal}
onSave={handleUpdatedFromMarketplace}
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
/>
)
}
</div>
)
}
export default DetailHeader
// Re-export from refactored module for backward compatibility
export { default } from './detail-header/index'

View File

@ -0,0 +1,539 @@
import type { PluginDetail } from '../../../types'
import type { ModalStates, VersionTarget } from '../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '../../../types'
import HeaderModals from './header-modals'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, title, onCancel, onConfirm, isLoading }: {
isShow: boolean
title: string
onCancel: () => void
onConfirm: () => void
isLoading: boolean
}) => isShow
? (
<div data-testid="delete-confirm">
<div data-testid="delete-title">{title}</div>
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button>
</div>
)
: null,
}))
vi.mock('@/app/components/plugins/plugin-page/plugin-info', () => ({
default: ({ repository, release, packageName, onHide }: {
repository: string
release: string
packageName: string
onHide: () => void
}) => (
<div data-testid="plugin-info">
<div data-testid="plugin-info-repo">{repository}</div>
<div data-testid="plugin-info-release">{release}</div>
<div data-testid="plugin-info-package">{packageName}</div>
<button data-testid="plugin-info-close" onClick={onHide}>Close</button>
</div>
),
}))
vi.mock('@/app/components/plugins/update-plugin/from-market-place', () => ({
default: ({ pluginId, onSave, onCancel, isShowDowngradeWarningModal }: {
pluginId: string
onSave: () => void
onCancel: () => void
isShowDowngradeWarningModal: boolean
}) => (
<div data-testid="update-modal">
<div data-testid="update-plugin-id">{pluginId}</div>
<div data-testid="update-downgrade-warning">{String(isShowDowngradeWarningModal)}</div>
<button data-testid="update-modal-save" onClick={onSave}>Save</button>
<button data-testid="update-modal-cancel" onClick={onCancel}>Cancel</button>
</div>
),
}))
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
author: 'test-author',
name: 'test-plugin-name',
category: 'tool',
label: { en_US: 'Test Plugin Label' },
description: { en_US: 'Test description' },
icon: 'icon.png',
verified: true,
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '2.0.0',
latest_unique_identifier: 'new-uid',
source: PluginSource.marketplace,
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
const createModalStatesMock = (overrides: Partial<ModalStates> = {}): ModalStates => ({
isShowUpdateModal: false,
showUpdateModal: vi.fn<() => void>(),
hideUpdateModal: vi.fn<() => void>(),
isShowPluginInfo: false,
showPluginInfo: vi.fn<() => void>(),
hidePluginInfo: vi.fn<() => void>(),
isShowDeleteConfirm: false,
showDeleteConfirm: vi.fn<() => void>(),
hideDeleteConfirm: vi.fn<() => void>(),
deleting: false,
showDeleting: vi.fn<() => void>(),
hideDeleting: vi.fn<() => void>(),
...overrides,
})
const createTargetVersion = (overrides: Partial<VersionTarget> = {}): VersionTarget => ({
version: '2.0.0',
unique_identifier: 'new-uid',
...overrides,
})
describe('HeaderModals', () => {
let mockOnUpdatedFromMarketplace: () => void
let mockOnDelete: () => void
beforeEach(() => {
vi.clearAllMocks()
mockOnUpdatedFromMarketplace = vi.fn<() => void>()
mockOnDelete = vi.fn<() => void>()
})
describe('Plugin Info Modal', () => {
it('should not render plugin info modal when isShowPluginInfo is false', () => {
const modalStates = createModalStatesMock({ isShowPluginInfo: false })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.queryByTestId('plugin-info')).not.toBeInTheDocument()
})
it('should render plugin info modal when isShowPluginInfo is true', () => {
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
})
it('should pass GitHub repo to plugin info for GitHub source', () => {
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'test-pkg' },
})
render(
<HeaderModals
detail={detail}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('owner/repo')
})
it('should pass empty string for repo for non-GitHub source', () => {
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
render(
<HeaderModals
detail={createPluginDetail({ source: PluginSource.marketplace })}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('')
})
it('should call hidePluginInfo when close button is clicked', () => {
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
fireEvent.click(screen.getByTestId('plugin-info-close'))
expect(modalStates.hidePluginInfo).toHaveBeenCalled()
})
})
describe('Delete Confirm Modal', () => {
it('should not render delete confirm when isShowDeleteConfirm is false', () => {
const modalStates = createModalStatesMock({ isShowDeleteConfirm: false })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
})
it('should render delete confirm when isShowDeleteConfirm is true', () => {
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
})
it('should show correct delete title', () => {
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('delete-title')).toHaveTextContent('action.delete')
})
it('should call hideDeleteConfirm when cancel is clicked', () => {
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
fireEvent.click(screen.getByTestId('confirm-cancel'))
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
})
it('should call onDelete when confirm is clicked', () => {
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
fireEvent.click(screen.getByTestId('confirm-ok'))
expect(mockOnDelete).toHaveBeenCalled()
})
it('should disable confirm button when deleting', () => {
const modalStates = createModalStatesMock({ isShowDeleteConfirm: true, deleting: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('confirm-ok')).toBeDisabled()
})
})
describe('Update Modal', () => {
it('should not render update modal when isShowUpdateModal is false', () => {
const modalStates = createModalStatesMock({ isShowUpdateModal: false })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.queryByTestId('update-modal')).not.toBeInTheDocument()
})
it('should render update modal when isShowUpdateModal is true', () => {
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
})
it('should pass plugin id to update modal', () => {
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
render(
<HeaderModals
detail={createPluginDetail({ plugin_id: 'my-plugin-id' })}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('update-plugin-id')).toHaveTextContent('my-plugin-id')
})
it('should call onUpdatedFromMarketplace when save is clicked', () => {
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
fireEvent.click(screen.getByTestId('update-modal-save'))
expect(mockOnUpdatedFromMarketplace).toHaveBeenCalled()
})
it('should call hideUpdateModal when cancel is clicked', () => {
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
fireEvent.click(screen.getByTestId('update-modal-cancel'))
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
})
it('should show downgrade warning when isDowngrade and isAutoUpgradeEnabled are true', () => {
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={true}
isAutoUpgradeEnabled={true}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('true')
})
it('should not show downgrade warning when only isDowngrade is true', () => {
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={true}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false')
})
it('should not show downgrade warning when only isAutoUpgradeEnabled is true', () => {
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={true}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false')
})
})
describe('Multiple Modals', () => {
it('should render multiple modals when multiple are open', () => {
const modalStates = createModalStatesMock({
isShowPluginInfo: true,
isShowDeleteConfirm: true,
isShowUpdateModal: true,
})
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined target version values', () => {
const modalStates = createModalStatesMock({ isShowUpdateModal: true })
render(
<HeaderModals
detail={createPluginDetail()}
modalStates={modalStates}
targetVersion={{ version: undefined, unique_identifier: undefined }}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
})
it('should handle empty meta for GitHub source', () => {
const modalStates = createModalStatesMock({ isShowPluginInfo: true })
const detail = createPluginDetail({
source: PluginSource.github,
meta: undefined,
})
render(
<HeaderModals
detail={detail}
modalStates={modalStates}
targetVersion={createTargetVersion()}
isDowngrade={false}
isAutoUpgradeEnabled={false}
onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace}
onDelete={mockOnDelete}
/>,
)
expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('')
expect(screen.getByTestId('plugin-info-package')).toHaveTextContent('')
})
})
})

View File

@ -0,0 +1,107 @@
'use client'
import type { FC } from 'react'
import type { PluginDetail } from '../../../types'
import type { ModalStates, VersionTarget } from '../hooks'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
import { useGetLanguage } from '@/context/i18n'
import { PluginSource } from '../../../types'
const i18nPrefix = 'action'
type HeaderModalsProps = {
detail: PluginDetail
modalStates: ModalStates
targetVersion: VersionTarget
isDowngrade: boolean
isAutoUpgradeEnabled: boolean
onUpdatedFromMarketplace: () => void
onDelete: () => void
}
const HeaderModals: FC<HeaderModalsProps> = ({
detail,
modalStates,
targetVersion,
isDowngrade,
isAutoUpgradeEnabled,
onUpdatedFromMarketplace,
onDelete,
}) => {
const { t } = useTranslation()
const locale = useGetLanguage()
const { source, version, meta } = detail
const { label } = detail.declaration || detail
const isFromGitHub = source === PluginSource.github
const {
isShowUpdateModal,
hideUpdateModal,
isShowPluginInfo,
hidePluginInfo,
isShowDeleteConfirm,
hideDeleteConfirm,
deleting,
} = modalStates
return (
<>
{/* Plugin Info Modal */}
{isShowPluginInfo && (
<PluginInfo
repository={isFromGitHub ? meta?.repo : ''}
release={version}
packageName={meta?.package || ''}
onHide={hidePluginInfo}
/>
)}
{/* Delete Confirm Modal */}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
content={(
<div>
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
<span className="system-md-semibold">{label[locale]}</span>
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
<br />
</div>
)}
onCancel={hideDeleteConfirm}
onConfirm={onDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
{/* Update from Marketplace Modal */}
{isShowUpdateModal && (
<UpdateFromMarketplace
pluginId={detail.plugin_id}
payload={{
category: detail.declaration?.category ?? '',
originalPackageInfo: {
id: detail.plugin_unique_identifier,
payload: detail.declaration ?? undefined,
},
targetPackageInfo: {
id: targetVersion.unique_identifier || '',
version: targetVersion.version || '',
},
}}
onCancel={hideUpdateModal}
onSave={onUpdatedFromMarketplace}
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
/>
)}
</>
)
}
export default HeaderModals

View File

@ -0,0 +1,2 @@
export { default as HeaderModals } from './header-modals'
export { default as PluginSourceBadge } from './plugin-source-badge'

View File

@ -0,0 +1,200 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '../../../types'
import PluginSourceBadge from './plugin-source-badge'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" data-content={popupContent}>
{children}
</div>
),
}))
describe('PluginSourceBadge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Source Icon Rendering', () => {
it('should render marketplace source badge', () => {
render(<PluginSourceBadge source={PluginSource.marketplace} />)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toBeInTheDocument()
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.marketplace')
})
it('should render github source badge', () => {
render(<PluginSourceBadge source={PluginSource.github} />)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toBeInTheDocument()
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.github')
})
it('should render local source badge', () => {
render(<PluginSourceBadge source={PluginSource.local} />)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toBeInTheDocument()
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.local')
})
it('should render debugging source badge', () => {
render(<PluginSourceBadge source={PluginSource.debugging} />)
const tooltip = screen.getByTestId('tooltip')
expect(tooltip).toBeInTheDocument()
expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.debugging')
})
})
describe('Separator Rendering', () => {
it('should render separator dot before marketplace badge', () => {
const { container } = render(<PluginSourceBadge source={PluginSource.marketplace} />)
const separator = container.querySelector('.text-text-quaternary')
expect(separator).toBeInTheDocument()
expect(separator?.textContent).toBe('·')
})
it('should render separator dot before github badge', () => {
const { container } = render(<PluginSourceBadge source={PluginSource.github} />)
const separator = container.querySelector('.text-text-quaternary')
expect(separator).toBeInTheDocument()
expect(separator?.textContent).toBe('·')
})
it('should render separator dot before local badge', () => {
const { container } = render(<PluginSourceBadge source={PluginSource.local} />)
const separator = container.querySelector('.text-text-quaternary')
expect(separator).toBeInTheDocument()
})
it('should render separator dot before debugging badge', () => {
const { container } = render(<PluginSourceBadge source={PluginSource.debugging} />)
const separator = container.querySelector('.text-text-quaternary')
expect(separator).toBeInTheDocument()
})
})
describe('Tooltip Content', () => {
it('should show marketplace tooltip', () => {
render(<PluginSourceBadge source={PluginSource.marketplace} />)
expect(screen.getByTestId('tooltip')).toHaveAttribute(
'data-content',
'detailPanel.categoryTip.marketplace',
)
})
it('should show github tooltip', () => {
render(<PluginSourceBadge source={PluginSource.github} />)
expect(screen.getByTestId('tooltip')).toHaveAttribute(
'data-content',
'detailPanel.categoryTip.github',
)
})
it('should show local tooltip', () => {
render(<PluginSourceBadge source={PluginSource.local} />)
expect(screen.getByTestId('tooltip')).toHaveAttribute(
'data-content',
'detailPanel.categoryTip.local',
)
})
it('should show debugging tooltip', () => {
render(<PluginSourceBadge source={PluginSource.debugging} />)
expect(screen.getByTestId('tooltip')).toHaveAttribute(
'data-content',
'detailPanel.categoryTip.debugging',
)
})
})
describe('Icon Element Structure', () => {
it('should render icon inside tooltip for marketplace', () => {
render(<PluginSourceBadge source={PluginSource.marketplace} />)
const tooltip = screen.getByTestId('tooltip')
const iconWrapper = tooltip.querySelector('div')
expect(iconWrapper).toBeInTheDocument()
})
it('should render icon inside tooltip for github', () => {
render(<PluginSourceBadge source={PluginSource.github} />)
const tooltip = screen.getByTestId('tooltip')
const iconWrapper = tooltip.querySelector('div')
expect(iconWrapper).toBeInTheDocument()
})
it('should render icon inside tooltip for local', () => {
render(<PluginSourceBadge source={PluginSource.local} />)
const tooltip = screen.getByTestId('tooltip')
const iconWrapper = tooltip.querySelector('div')
expect(iconWrapper).toBeInTheDocument()
})
it('should render icon inside tooltip for debugging', () => {
render(<PluginSourceBadge source={PluginSource.debugging} />)
const tooltip = screen.getByTestId('tooltip')
const iconWrapper = tooltip.querySelector('div')
expect(iconWrapper).toBeInTheDocument()
})
})
describe('Lookup Table Coverage', () => {
it('should handle all PluginSource enum values', () => {
const allSources = Object.values(PluginSource)
allSources.forEach((source) => {
const { container } = render(<PluginSourceBadge source={source} />)
// Should render either tooltip or nothing
expect(container).toBeTruthy()
})
})
})
describe('Invalid Source Handling', () => {
it('should return null for unknown source type', () => {
// Use type assertion to test invalid source value
const invalidSource = 'unknown_source' as PluginSource
const { container } = render(<PluginSourceBadge source={invalidSource} />)
// Should render nothing (empty container)
expect(container.firstChild).toBeNull()
})
it('should not render separator for invalid source', () => {
const invalidSource = 'invalid' as PluginSource
const { container } = render(<PluginSourceBadge source={invalidSource} />)
const separator = container.querySelector('.text-text-quaternary')
expect(separator).not.toBeInTheDocument()
})
it('should not render tooltip for invalid source', () => {
const invalidSource = '' as PluginSource
render(<PluginSourceBadge source={invalidSource} />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,59 @@
'use client'
import type { FC, ReactNode } from 'react'
import {
RiBugLine,
RiHardDrive3Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Github } from '@/app/components/base/icons/src/public/common'
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
import Tooltip from '@/app/components/base/tooltip'
import { PluginSource } from '../../../types'
type SourceConfig = {
icon: ReactNode
tipKey: string
}
type PluginSourceBadgeProps = {
source: PluginSource
}
const SOURCE_CONFIG_MAP: Record<PluginSource, SourceConfig | null> = {
[PluginSource.marketplace]: {
icon: <BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" />,
tipKey: 'detailPanel.categoryTip.marketplace',
},
[PluginSource.github]: {
icon: <Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" />,
tipKey: 'detailPanel.categoryTip.github',
},
[PluginSource.local]: {
icon: <RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" />,
tipKey: 'detailPanel.categoryTip.local',
},
[PluginSource.debugging]: {
icon: <RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" />,
tipKey: 'detailPanel.categoryTip.debugging',
},
}
const PluginSourceBadge: FC<PluginSourceBadgeProps> = ({ source }) => {
const { t } = useTranslation()
const config = SOURCE_CONFIG_MAP[source]
if (!config)
return null
return (
<>
<div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div>
<Tooltip popupContent={t(config.tipKey as never, { ns: 'plugin' })}>
<div>{config.icon}</div>
</Tooltip>
</>
)
}
export default PluginSourceBadge

View File

@ -0,0 +1,3 @@
export { useDetailHeaderState } from './use-detail-header-state'
export type { ModalStates, UseDetailHeaderStateReturn, VersionPickerState, VersionTarget } from './use-detail-header-state'
export { usePluginOperations } from './use-plugin-operations'

View File

@ -0,0 +1,409 @@
import type { PluginDetail } from '../../../types'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '../../../types'
import { useDetailHeaderState } from './use-detail-header-state'
let mockEnableMarketplace = true
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) =>
selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
}))
let mockAutoUpgradeInfo: {
strategy_setting: string
upgrade_mode: string
include_plugins: string[]
exclude_plugins: string[]
upgrade_time_of_day: number
} | null = null
vi.mock('../../../plugin-page/use-reference-setting', () => ({
default: () => ({
referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null,
}),
}))
vi.mock('../../../reference-setting-modal/auto-update-setting/types', () => ({
AUTO_UPDATE_MODE: {
update_all: 'update_all',
partial: 'partial',
exclude: 'exclude',
},
}))
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
author: 'test-author',
name: 'test-plugin-name',
category: 'tool',
label: { en_US: 'Test Plugin Label' },
description: { en_US: 'Test description' },
icon: 'icon.png',
verified: true,
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: PluginSource.marketplace,
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
describe('useDetailHeaderState', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAutoUpgradeInfo = null
mockEnableMarketplace = true
})
describe('Source Type Detection', () => {
it('should detect marketplace source', () => {
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isFromMarketplace).toBe(true)
expect(result.current.isFromGitHub).toBe(false)
})
it('should detect GitHub source', () => {
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
})
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isFromGitHub).toBe(true)
expect(result.current.isFromMarketplace).toBe(false)
})
it('should detect local source', () => {
const detail = createPluginDetail({ source: PluginSource.local })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isFromGitHub).toBe(false)
expect(result.current.isFromMarketplace).toBe(false)
})
})
describe('Version State', () => {
it('should detect new version available for marketplace plugin', () => {
const detail = createPluginDetail({
version: '1.0.0',
latest_version: '2.0.0',
source: PluginSource.marketplace,
})
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.hasNewVersion).toBe(true)
})
it('should not detect new version when versions match', () => {
const detail = createPluginDetail({
version: '1.0.0',
latest_version: '1.0.0',
source: PluginSource.marketplace,
})
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.hasNewVersion).toBe(false)
})
it('should not detect new version for non-marketplace source', () => {
const detail = createPluginDetail({
version: '1.0.0',
latest_version: '2.0.0',
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
})
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.hasNewVersion).toBe(false)
})
it('should not detect new version when latest_version is empty', () => {
const detail = createPluginDetail({
version: '1.0.0',
latest_version: '',
source: PluginSource.marketplace,
})
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.hasNewVersion).toBe(false)
})
})
describe('Version Picker State', () => {
it('should initialize version picker as hidden', () => {
const detail = createPluginDetail()
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.versionPicker.isShow).toBe(false)
})
it('should toggle version picker visibility', () => {
const detail = createPluginDetail()
const { result } = renderHook(() => useDetailHeaderState(detail))
act(() => {
result.current.versionPicker.setIsShow(true)
})
expect(result.current.versionPicker.isShow).toBe(true)
act(() => {
result.current.versionPicker.setIsShow(false)
})
expect(result.current.versionPicker.isShow).toBe(false)
})
it('should update target version', () => {
const detail = createPluginDetail()
const { result } = renderHook(() => useDetailHeaderState(detail))
act(() => {
result.current.versionPicker.setTargetVersion({
version: '2.0.0',
unique_identifier: 'new-uid',
})
})
expect(result.current.versionPicker.targetVersion.version).toBe('2.0.0')
expect(result.current.versionPicker.targetVersion.unique_identifier).toBe('new-uid')
})
it('should set isDowngrade when provided in target version', () => {
const detail = createPluginDetail()
const { result } = renderHook(() => useDetailHeaderState(detail))
act(() => {
result.current.versionPicker.setTargetVersion({
version: '0.5.0',
unique_identifier: 'old-uid',
isDowngrade: true,
})
})
expect(result.current.versionPicker.isDowngrade).toBe(true)
})
})
describe('Modal States', () => {
it('should initialize all modals as hidden', () => {
const detail = createPluginDetail()
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.modalStates.isShowUpdateModal).toBe(false)
expect(result.current.modalStates.isShowPluginInfo).toBe(false)
expect(result.current.modalStates.isShowDeleteConfirm).toBe(false)
expect(result.current.modalStates.deleting).toBe(false)
})
it('should toggle update modal', () => {
const detail = createPluginDetail()
const { result } = renderHook(() => useDetailHeaderState(detail))
act(() => {
result.current.modalStates.showUpdateModal()
})
expect(result.current.modalStates.isShowUpdateModal).toBe(true)
act(() => {
result.current.modalStates.hideUpdateModal()
})
expect(result.current.modalStates.isShowUpdateModal).toBe(false)
})
it('should toggle plugin info modal', () => {
const detail = createPluginDetail()
const { result } = renderHook(() => useDetailHeaderState(detail))
act(() => {
result.current.modalStates.showPluginInfo()
})
expect(result.current.modalStates.isShowPluginInfo).toBe(true)
act(() => {
result.current.modalStates.hidePluginInfo()
})
expect(result.current.modalStates.isShowPluginInfo).toBe(false)
})
it('should toggle delete confirm modal', () => {
const detail = createPluginDetail()
const { result } = renderHook(() => useDetailHeaderState(detail))
act(() => {
result.current.modalStates.showDeleteConfirm()
})
expect(result.current.modalStates.isShowDeleteConfirm).toBe(true)
act(() => {
result.current.modalStates.hideDeleteConfirm()
})
expect(result.current.modalStates.isShowDeleteConfirm).toBe(false)
})
it('should toggle deleting state', () => {
const detail = createPluginDetail()
const { result } = renderHook(() => useDetailHeaderState(detail))
act(() => {
result.current.modalStates.showDeleting()
})
expect(result.current.modalStates.deleting).toBe(true)
act(() => {
result.current.modalStates.hideDeleting()
})
expect(result.current.modalStates.deleting).toBe(false)
})
})
describe('Auto Upgrade Detection', () => {
it('should disable auto upgrade when marketplace is disabled', () => {
mockEnableMarketplace = false
mockAutoUpgradeInfo = {
strategy_setting: 'enabled',
upgrade_mode: 'update_all',
include_plugins: [],
exclude_plugins: [],
upgrade_time_of_day: 36000,
}
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isAutoUpgradeEnabled).toBe(false)
})
it('should disable auto upgrade when strategy is disabled', () => {
mockAutoUpgradeInfo = {
strategy_setting: 'disabled',
upgrade_mode: 'update_all',
include_plugins: [],
exclude_plugins: [],
upgrade_time_of_day: 36000,
}
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isAutoUpgradeEnabled).toBe(false)
})
it('should enable auto upgrade for update_all mode', () => {
mockAutoUpgradeInfo = {
strategy_setting: 'enabled',
upgrade_mode: 'update_all',
include_plugins: [],
exclude_plugins: [],
upgrade_time_of_day: 36000,
}
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isAutoUpgradeEnabled).toBe(true)
})
it('should enable auto upgrade for partial mode when plugin is included', () => {
mockAutoUpgradeInfo = {
strategy_setting: 'enabled',
upgrade_mode: 'partial',
include_plugins: ['test-plugin'],
exclude_plugins: [],
upgrade_time_of_day: 36000,
}
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isAutoUpgradeEnabled).toBe(true)
})
it('should disable auto upgrade for partial mode when plugin is not included', () => {
mockAutoUpgradeInfo = {
strategy_setting: 'enabled',
upgrade_mode: 'partial',
include_plugins: ['other-plugin'],
exclude_plugins: [],
upgrade_time_of_day: 36000,
}
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isAutoUpgradeEnabled).toBe(false)
})
it('should enable auto upgrade for exclude mode when plugin is not excluded', () => {
mockAutoUpgradeInfo = {
strategy_setting: 'enabled',
upgrade_mode: 'exclude',
include_plugins: [],
exclude_plugins: ['other-plugin'],
upgrade_time_of_day: 36000,
}
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isAutoUpgradeEnabled).toBe(true)
})
it('should disable auto upgrade for exclude mode when plugin is excluded', () => {
mockAutoUpgradeInfo = {
strategy_setting: 'enabled',
upgrade_mode: 'exclude',
include_plugins: [],
exclude_plugins: ['test-plugin'],
upgrade_time_of_day: 36000,
}
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isAutoUpgradeEnabled).toBe(false)
})
it('should disable auto upgrade for non-marketplace source', () => {
mockAutoUpgradeInfo = {
strategy_setting: 'enabled',
upgrade_mode: 'update_all',
include_plugins: [],
exclude_plugins: [],
upgrade_time_of_day: 36000,
}
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
})
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isAutoUpgradeEnabled).toBe(false)
})
it('should disable auto upgrade when no auto upgrade info', () => {
mockAutoUpgradeInfo = null
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() => useDetailHeaderState(detail))
expect(result.current.isAutoUpgradeEnabled).toBe(false)
})
})
})

View File

@ -0,0 +1,132 @@
'use client'
import type { PluginDetail } from '../../../types'
import { useBoolean } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useReferenceSetting from '../../../plugin-page/use-reference-setting'
import { AUTO_UPDATE_MODE } from '../../../reference-setting-modal/auto-update-setting/types'
import { PluginSource } from '../../../types'
export type VersionTarget = {
version: string | undefined
unique_identifier: string | undefined
isDowngrade?: boolean
}
export type ModalStates = {
isShowUpdateModal: boolean
showUpdateModal: () => void
hideUpdateModal: () => void
isShowPluginInfo: boolean
showPluginInfo: () => void
hidePluginInfo: () => void
isShowDeleteConfirm: boolean
showDeleteConfirm: () => void
hideDeleteConfirm: () => void
deleting: boolean
showDeleting: () => void
hideDeleting: () => void
}
export type VersionPickerState = {
isShow: boolean
setIsShow: (show: boolean) => void
targetVersion: VersionTarget
setTargetVersion: (version: VersionTarget) => void
isDowngrade: boolean
setIsDowngrade: (downgrade: boolean) => void
}
export type UseDetailHeaderStateReturn = {
modalStates: ModalStates
versionPicker: VersionPickerState
hasNewVersion: boolean
isAutoUpgradeEnabled: boolean
isFromGitHub: boolean
isFromMarketplace: boolean
}
export const useDetailHeaderState = (detail: PluginDetail): UseDetailHeaderStateReturn => {
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const { referenceSetting } = useReferenceSetting()
const {
source,
version,
latest_version,
latest_unique_identifier,
plugin_id,
} = detail
const isFromGitHub = source === PluginSource.github
const isFromMarketplace = source === PluginSource.marketplace
const [isShow, setIsShow] = useState(false)
const [targetVersion, setTargetVersion] = useState<VersionTarget>({
version: latest_version,
unique_identifier: latest_unique_identifier,
})
const [isDowngrade, setIsDowngrade] = useState(false)
const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = useBoolean(false)
const [isShowPluginInfo, { setTrue: showPluginInfo, setFalse: hidePluginInfo }] = useBoolean(false)
const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = useBoolean(false)
const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false)
const hasNewVersion = useMemo(() => {
if (isFromMarketplace)
return !!latest_version && latest_version !== version
return false
}, [isFromMarketplace, latest_version, version])
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
const isAutoUpgradeEnabled = useMemo(() => {
if (!enable_marketplace || !autoUpgradeInfo || !isFromMarketplace)
return false
if (autoUpgradeInfo.strategy_setting === 'disabled')
return false
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
return true
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
return true
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
return true
return false
}, [autoUpgradeInfo, plugin_id, isFromMarketplace, enable_marketplace])
const handleSetTargetVersion = useCallback((version: VersionTarget) => {
setTargetVersion(version)
if (version.isDowngrade !== undefined)
setIsDowngrade(version.isDowngrade)
}, [])
return {
modalStates: {
isShowUpdateModal,
showUpdateModal,
hideUpdateModal,
isShowPluginInfo,
showPluginInfo,
hidePluginInfo,
isShowDeleteConfirm,
showDeleteConfirm,
hideDeleteConfirm,
deleting,
showDeleting,
hideDeleting,
},
versionPicker: {
isShow,
setIsShow,
targetVersion,
setTargetVersion: handleSetTargetVersion,
isDowngrade,
setIsDowngrade,
},
hasNewVersion,
isAutoUpgradeEnabled,
isFromGitHub,
isFromMarketplace,
}
}

View File

@ -0,0 +1,549 @@
import type { PluginDetail } from '../../../types'
import type { ModalStates, VersionTarget } from './use-detail-header-state'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as amplitude from '@/app/components/base/amplitude'
import Toast from '@/app/components/base/toast'
import { PluginSource } from '../../../types'
import { usePluginOperations } from './use-plugin-operations'
type VersionPickerMock = {
setTargetVersion: (version: VersionTarget) => void
setIsDowngrade: (downgrade: boolean) => void
}
const {
mockSetShowUpdatePluginModal,
mockRefreshModelProviders,
mockInvalidateAllToolProviders,
mockUninstallPlugin,
mockFetchReleases,
mockCheckForUpdates,
} = vi.hoisted(() => {
return {
mockSetShowUpdatePluginModal: vi.fn(),
mockRefreshModelProviders: vi.fn(),
mockInvalidateAllToolProviders: vi.fn(),
mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })),
mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])),
mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })),
}
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowUpdatePluginModal: mockSetShowUpdatePluginModal,
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
refreshModelProviders: mockRefreshModelProviders,
}),
}))
vi.mock('@/service/plugins', () => ({
uninstallPlugin: mockUninstallPlugin,
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
}))
vi.mock('../../../install-plugin/hooks', () => ({
useGitHubReleases: () => ({
checkForUpdates: mockCheckForUpdates,
fetchReleases: mockFetchReleases,
}),
}))
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
author: 'test-author',
name: 'test-plugin-name',
category: 'tool',
label: { en_US: 'Test Plugin Label' },
description: { en_US: 'Test description' },
icon: 'icon.png',
verified: true,
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '2.0.0',
latest_unique_identifier: 'new-uid',
source: PluginSource.marketplace,
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
const createModalStatesMock = (): ModalStates => ({
isShowUpdateModal: false,
showUpdateModal: vi.fn(),
hideUpdateModal: vi.fn(),
isShowPluginInfo: false,
showPluginInfo: vi.fn(),
hidePluginInfo: vi.fn(),
isShowDeleteConfirm: false,
showDeleteConfirm: vi.fn(),
hideDeleteConfirm: vi.fn(),
deleting: false,
showDeleting: vi.fn(),
hideDeleting: vi.fn(),
})
const createVersionPickerMock = (): VersionPickerMock => ({
setTargetVersion: vi.fn<(version: VersionTarget) => void>(),
setIsDowngrade: vi.fn<(downgrade: boolean) => void>(),
})
describe('usePluginOperations', () => {
let modalStates: ModalStates
let versionPicker: VersionPickerMock
let mockOnUpdate: (isDelete?: boolean) => void
beforeEach(() => {
vi.clearAllMocks()
modalStates = createModalStatesMock()
versionPicker = createVersionPickerMock()
mockOnUpdate = vi.fn()
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {})
})
describe('Marketplace Update Flow', () => {
it('should show update modal for marketplace plugin', async () => {
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleUpdate()
})
expect(modalStates.showUpdateModal).toHaveBeenCalled()
})
it('should set isDowngrade when downgrading', async () => {
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleUpdate(true)
})
expect(versionPicker.setIsDowngrade).toHaveBeenCalledWith(true)
expect(modalStates.showUpdateModal).toHaveBeenCalled()
})
it('should call onUpdate and hide modal on successful marketplace update', () => {
const detail = createPluginDetail({ source: PluginSource.marketplace })
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
act(() => {
result.current.handleUpdatedFromMarketplace()
})
expect(mockOnUpdate).toHaveBeenCalled()
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
})
})
describe('GitHub Update Flow', () => {
it('should fetch releases from GitHub', async () => {
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
})
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: false,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleUpdate()
})
expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo')
})
it('should check for updates after fetching releases', async () => {
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
})
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: false,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleUpdate()
})
expect(mockCheckForUpdates).toHaveBeenCalled()
expect(Toast.notify).toHaveBeenCalled()
})
it('should show update plugin modal when update is needed', async () => {
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
})
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: false,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleUpdate()
})
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
})
it('should not show modal when no releases found', async () => {
mockFetchReleases.mockResolvedValueOnce([])
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
})
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: false,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleUpdate()
})
expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled()
})
it('should not show modal when no update needed', async () => {
mockCheckForUpdates.mockReturnValueOnce({
needUpdate: false,
toastProps: { type: 'info', message: 'Already up to date' },
})
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
})
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: false,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleUpdate()
})
expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled()
})
it('should use author and name as fallback for repo parsing', async () => {
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: '/', version: 'v1.0.0', package: 'pkg' },
declaration: {
author: 'fallback-author',
name: 'fallback-name',
category: 'tool',
label: { en_US: 'Test' },
description: { en_US: 'Test' },
icon: 'icon.png',
verified: true,
} as unknown as PluginDetail['declaration'],
})
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: false,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleUpdate()
})
expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-name')
})
})
describe('Delete Flow', () => {
it('should call uninstallPlugin with correct id', async () => {
const detail = createPluginDetail({ id: 'plugin-to-delete' })
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleDelete()
})
expect(mockUninstallPlugin).toHaveBeenCalledWith('plugin-to-delete')
})
it('should show and hide deleting state during delete', async () => {
const detail = createPluginDetail()
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleDelete()
})
expect(modalStates.showDeleting).toHaveBeenCalled()
expect(modalStates.hideDeleting).toHaveBeenCalled()
})
it('should call onUpdate with true after successful delete', async () => {
const detail = createPluginDetail()
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleDelete()
})
expect(mockOnUpdate).toHaveBeenCalledWith(true)
})
it('should hide delete confirm after successful delete', async () => {
const detail = createPluginDetail()
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleDelete()
})
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
})
it('should refresh model providers when deleting model plugin', async () => {
const detail = createPluginDetail({
declaration: {
author: 'test-author',
name: 'test-plugin-name',
category: 'model',
label: { en_US: 'Test' },
description: { en_US: 'Test' },
icon: 'icon.png',
verified: true,
} as unknown as PluginDetail['declaration'],
})
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleDelete()
})
expect(mockRefreshModelProviders).toHaveBeenCalled()
})
it('should invalidate tool providers when deleting tool plugin', async () => {
const detail = createPluginDetail({
declaration: {
author: 'test-author',
name: 'test-plugin-name',
category: 'tool',
label: { en_US: 'Test' },
description: { en_US: 'Test' },
icon: 'icon.png',
verified: true,
} as unknown as PluginDetail['declaration'],
})
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleDelete()
})
expect(mockInvalidateAllToolProviders).toHaveBeenCalled()
})
it('should track plugin uninstalled event', async () => {
const detail = createPluginDetail()
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleDelete()
})
expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.objectContaining({
plugin_id: 'test-plugin',
plugin_name: 'test-plugin-name',
}))
})
it('should not call onUpdate when delete fails', async () => {
mockUninstallPlugin.mockResolvedValueOnce({ success: false })
const detail = createPluginDetail()
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleDelete()
})
expect(mockOnUpdate).not.toHaveBeenCalled()
})
})
describe('Optional onUpdate Callback', () => {
it('should not throw when onUpdate is not provided for marketplace update', () => {
const detail = createPluginDetail()
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
}),
)
expect(() => {
result.current.handleUpdatedFromMarketplace()
}).not.toThrow()
})
it('should not throw when onUpdate is not provided for delete', async () => {
const detail = createPluginDetail()
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: true,
}),
)
await expect(
act(async () => {
await result.current.handleDelete()
}),
).resolves.not.toThrow()
})
})
})

View File

@ -0,0 +1,143 @@
'use client'
import type { PluginDetail } from '../../../types'
import type { ModalStates, VersionTarget } from './use-detail-header-state'
import { useCallback } from 'react'
import { trackEvent } from '@/app/components/base/amplitude'
import Toast from '@/app/components/base/toast'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { uninstallPlugin } from '@/service/plugins'
import { useInvalidateAllToolProviders } from '@/service/use-tools'
import { useGitHubReleases } from '../../../install-plugin/hooks'
import { PluginCategoryEnum, PluginSource } from '../../../types'
type UsePluginOperationsParams = {
detail: PluginDetail
modalStates: ModalStates
versionPicker: {
setTargetVersion: (version: VersionTarget) => void
setIsDowngrade: (downgrade: boolean) => void
}
isFromMarketplace: boolean
onUpdate?: (isDelete?: boolean) => void
}
type UsePluginOperationsReturn = {
handleUpdate: (isDowngrade?: boolean) => Promise<void>
handleUpdatedFromMarketplace: () => void
handleDelete: () => Promise<void>
}
export const usePluginOperations = ({
detail,
modalStates,
versionPicker,
isFromMarketplace,
onUpdate,
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext()
const { refreshModelProviders } = useProviderContext()
const invalidateAllToolProviders = useInvalidateAllToolProviders()
const { id, meta, plugin_id } = detail
const { author, category, name } = detail.declaration || detail
const handleUpdate = useCallback(async (isDowngrade?: boolean) => {
if (isFromMarketplace) {
versionPicker.setIsDowngrade(!!isDowngrade)
modalStates.showUpdateModal()
return
}
if (!meta?.repo || !meta?.version || !meta?.package) {
Toast.notify({
type: 'error',
message: 'Missing plugin metadata for GitHub update',
})
return
}
const owner = meta.repo.split('/')[0] || author
const repo = meta.repo.split('/')[1] || name
const fetchedReleases = await fetchReleases(owner, repo)
if (fetchedReleases.length === 0)
return
const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta.version)
Toast.notify(toastProps)
if (needUpdate) {
setShowUpdatePluginModal({
onSaveCallback: () => {
onUpdate?.()
},
payload: {
type: PluginSource.github,
category,
github: {
originalPackageInfo: {
id: detail.plugin_unique_identifier,
repo: meta.repo,
version: meta.version,
package: meta.package,
releases: fetchedReleases,
},
},
},
})
}
}, [
isFromMarketplace,
meta,
author,
name,
fetchReleases,
checkForUpdates,
setShowUpdatePluginModal,
detail,
onUpdate,
modalStates,
versionPicker,
])
const handleUpdatedFromMarketplace = useCallback(() => {
onUpdate?.()
modalStates.hideUpdateModal()
}, [onUpdate, modalStates])
const handleDelete = useCallback(async () => {
modalStates.showDeleting()
const res = await uninstallPlugin(id)
modalStates.hideDeleting()
if (res.success) {
modalStates.hideDeleteConfirm()
onUpdate?.(true)
if (PluginCategoryEnum.model.includes(category))
refreshModelProviders()
if (PluginCategoryEnum.tool.includes(category))
invalidateAllToolProviders()
trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name })
}
}, [
id,
category,
plugin_id,
name,
modalStates,
onUpdate,
refreshModelProviders,
invalidateAllToolProviders,
])
return {
handleUpdate,
handleUpdatedFromMarketplace,
handleDelete,
}
}

View File

@ -0,0 +1,286 @@
'use client'
import type { PluginDetail } from '../../types'
import {
RiArrowLeftRightLine,
RiCloseLine,
} from '@remixicon/react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
import { API_PREFIX } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGetLanguage, useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { useAllToolProviders } from '@/service/use-tools'
import { cn } from '@/utils/classnames'
import { getMarketplaceUrl } from '@/utils/var'
import { AutoUpdateLine } from '../../../base/icons/src/vender/system'
import Verified from '../../base/badges/verified'
import DeprecationNotice from '../../base/deprecation-notice'
import Icon from '../../card/base/card-icon'
import Description from '../../card/base/description'
import OrgInfo from '../../card/base/org-info'
import Title from '../../card/base/title'
import useReferenceSetting from '../../plugin-page/use-reference-setting'
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../../reference-setting-modal/auto-update-setting/utils'
import { PluginCategoryEnum, PluginSource } from '../../types'
import { HeaderModals, PluginSourceBadge } from './components'
import { useDetailHeaderState, usePluginOperations } from './hooks'
type Props = {
detail: PluginDetail
isReadmeView?: boolean
onHide?: () => void
onUpdate?: (isDelete?: boolean) => void
}
const getIconSrc = (icon: string | undefined, iconDark: string | undefined, theme: string, tenantId: string): string => {
const iconFileName = theme === 'dark' && iconDark ? iconDark : icon
if (!iconFileName)
return ''
return iconFileName.startsWith('http')
? iconFileName
: `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenantId}&filename=${iconFileName}`
}
const getDetailUrl = (
source: PluginSource,
meta: PluginDetail['meta'],
author: string,
name: string,
locale: string,
theme: string,
): string => {
if (source === PluginSource.github) {
const repo = meta?.repo
if (!repo)
return ''
return `https://github.com/${repo}`
}
if (source === PluginSource.marketplace)
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
return ''
}
const DetailHeader = ({
detail,
isReadmeView = false,
onHide,
onUpdate,
}: Props) => {
const { t } = useTranslation()
const { userProfile: { timezone } } = useAppContext()
const { theme } = useTheme()
const locale = useGetLanguage()
const currentLocale = useLocale()
const { referenceSetting } = useReferenceSetting()
const {
source,
tenant_id,
version,
latest_version,
latest_unique_identifier,
meta,
plugin_id,
status,
deprecated_reason,
alternative_plugin_id,
} = detail
const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
const {
modalStates,
versionPicker,
hasNewVersion,
isAutoUpgradeEnabled,
isFromGitHub,
isFromMarketplace,
} = useDetailHeaderState(detail)
const {
handleUpdate,
handleUpdatedFromMarketplace,
handleDelete,
} = usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace,
onUpdate,
})
const isTool = category === PluginCategoryEnum.tool
const providerBriefInfo = tool?.identity
const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
const { data: collectionList = [] } = useAllToolProviders(isTool)
const provider = useMemo(() => {
return collectionList.find(collection => collection.name === providerKey)
}, [collectionList, providerKey])
const iconSrc = getIconSrc(icon, icon_dark, theme, tenant_id)
const detailUrl = getDetailUrl(source, meta, author, name, currentLocale, theme)
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => {
versionPicker.setTargetVersion(state)
handleUpdate(state.isDowngrade)
}
const handleTriggerLatestUpdate = () => {
if (isFromMarketplace) {
versionPicker.setTargetVersion({
version: latest_version,
unique_identifier: latest_unique_identifier,
})
}
handleUpdate()
}
return (
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
<div className="flex">
{/* Plugin Icon */}
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
<Icon src={iconSrc} />
</div>
{/* Plugin Info */}
<div className="ml-3 w-0 grow">
{/* Title Row */}
<div className="flex h-5 items-center">
<Title title={label[locale]} />
{verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />}
{/* Version Picker */}
{!!version && (
<PluginVersionPicker
disabled={!isFromMarketplace || isReadmeView}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
trigger={(
<Badge
className={cn(
'mx-1',
versionPicker.isShow && 'bg-state-base-hover',
(versionPicker.isShow || isFromMarketplace) && 'hover:bg-state-base-hover',
)}
uppercase={false}
text={(
<>
<div>{isFromGitHub ? (meta?.version ?? version ?? '') : version}</div>
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
</>
)}
hasRedCornerMark={hasNewVersion}
/>
)}
/>
)}
{/* Auto Update Badge */}
{isAutoUpgradeEnabled && !isReadmeView && (
<Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
<div>
<Badge className="mr-1 cursor-pointer px-1">
<AutoUpdateLine className="size-3" />
</Badge>
</div>
</Tooltip>
)}
{/* Update Button */}
{(hasNewVersion || isFromGitHub) && (
<Button
variant="secondary-accent"
size="small"
className="!h-5"
onClick={handleTriggerLatestUpdate}
>
{t('detailPanel.operation.update', { ns: 'plugin' })}
</Button>
)}
</div>
{/* Org Info Row */}
<div className="mb-1 flex h-4 items-center justify-between">
<div className="mt-0.5 flex items-center">
<OrgInfo
packageNameClassName="w-auto"
orgName={author}
packageName={name?.includes('/') ? (name.split('/').pop() || '') : name}
/>
{!!source && <PluginSourceBadge source={source} />}
</div>
</div>
</div>
{/* Action Buttons */}
{!isReadmeView && (
<div className="flex gap-1">
<OperationDropdown
source={source}
onInfo={modalStates.showPluginInfo}
onCheckVersion={handleUpdate}
onRemove={modalStates.showDeleteConfirm}
detailUrl={detailUrl}
/>
<ActionButton onClick={onHide}>
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
)}
</div>
{/* Deprecation Notice */}
{isFromMarketplace && (
<DeprecationNotice
status={status}
deprecatedReason={deprecated_reason}
alternativePluginId={alternative_plugin_id}
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
className="mt-3"
/>
)}
{/* Description */}
{!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2} />}
{/* Plugin Auth for Tools */}
{category === PluginCategoryEnum.tool && !isReadmeView && (
<PluginAuth
pluginPayload={{
provider: provider?.name || '',
category: AuthCategory.tool,
providerType: provider?.type || '',
detail,
}}
/>
)}
{/* Modals */}
<HeaderModals
detail={detail}
modalStates={modalStates}
targetVersion={versionPicker.targetVersion}
isDowngrade={versionPicker.isDowngrade}
isAutoUpgradeEnabled={isAutoUpgradeEnabled}
onUpdatedFromMarketplace={handleUpdatedFromMarketplace}
onDelete={handleDelete}
/>
</div>
)
}
export default DetailHeader

View File

@ -2,15 +2,10 @@ import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Import after mocks
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { CommonCreateModal } from './common-modal'
// ============================================================================
// Type Definitions
// ============================================================================
type PluginDetail = {
plugin_id: string
provider: string
@ -33,10 +28,6 @@ type TriggerLogEntity = {
level: 'info' | 'warn' | 'error'
}
// ============================================================================
// Mock Factory Functions
// ============================================================================
function createMockPluginDetail(overrides: Partial<PluginDetail> = {}): PluginDetail {
return {
plugin_id: 'test-plugin-id',
@ -74,18 +65,12 @@ function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEnt
return { logs }
}
// ============================================================================
// Mock Setup
// ============================================================================
// Mock plugin store
const mockPluginDetail = createMockPluginDetail()
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
vi.mock('../../store', () => ({
usePluginStore: () => mockUsePluginStore(),
}))
// Mock subscription list hook
const mockRefetch = vi.fn()
vi.mock('../use-subscription-list', () => ({
useSubscriptionList: () => ({
@ -93,13 +78,11 @@ vi.mock('../use-subscription-list', () => ({
}),
}))
// Mock service hooks
const mockVerifyCredentials = vi.fn()
const mockCreateBuilder = vi.fn()
const mockBuildSubscription = vi.fn()
const mockUpdateBuilder = vi.fn()
// Configurable pending states
let mockIsVerifyingCredentials = false
let mockIsBuilding = false
const setMockPendingStates = (verifying: boolean, building: boolean) => {
@ -129,18 +112,15 @@ vi.mock('@/service/use-triggers', () => ({
}),
}))
// Mock error parser
const mockParsePluginErrorMessage = vi.fn().mockResolvedValue(null)
vi.mock('@/utils/error-parser', () => ({
parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args),
}))
// Mock URL validation
vi.mock('@/utils/urlValidation', () => ({
isPrivateOrLocalAddress: vi.fn().mockReturnValue(false),
}))
// Mock toast
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
@ -148,7 +128,6 @@ vi.mock('@/app/components/base/toast', () => ({
},
}))
// Mock Modal component
vi.mock('@/app/components/base/modal/modal', () => ({
default: ({
children,
@ -179,7 +158,6 @@ vi.mock('@/app/components/base/modal/modal', () => ({
),
}))
// Configurable form mock values
type MockFormValuesConfig = {
values: Record<string, unknown>
isCheckValidated: boolean
@ -190,7 +168,6 @@ let mockFormValuesConfig: MockFormValuesConfig = {
}
let mockGetFormReturnsNull = false
// Separate validation configs for different forms
let mockSubscriptionFormValidated = true
let mockAutoParamsFormValidated = true
let mockManualPropsFormValidated = true
@ -207,7 +184,6 @@ const setMockFormValidation = (subscription: boolean, autoParams: boolean, manua
mockManualPropsFormValidated = manualProps
}
// Mock BaseForm component with ref support
vi.mock('@/app/components/base/form/components/base', async () => {
const React = await import('react')
@ -219,7 +195,6 @@ vi.mock('@/app/components/base/form/components/base', async () => {
type MockBaseFormProps = { formSchemas: Array<{ name: string }>, onChange?: () => void }
function MockBaseFormInner({ formSchemas, onChange }: MockBaseFormProps, ref: React.ForwardedRef<MockFormRef>) {
// Determine which form this is based on schema
const isSubscriptionForm = formSchemas.some((s: { name: string }) => s.name === 'subscription_name')
const isAutoParamsForm = formSchemas.some((s: { name: string }) =>
['repo_name', 'branch', 'repo', 'text_field', 'dynamic_field', 'bool_field', 'text_input_field', 'unknown_field', 'count'].includes(s.name),
@ -265,12 +240,10 @@ vi.mock('@/app/components/base/form/components/base', async () => {
}
})
// Mock EncryptedBottom component
vi.mock('@/app/components/base/encrypted-bottom', () => ({
EncryptedBottom: () => <div data-testid="encrypted-bottom">Encrypted</div>,
}))
// Mock LogViewer component
vi.mock('../log-viewer', () => ({
default: ({ logs }: { logs: TriggerLogEntity[] }) => (
<div data-testid="log-viewer">
@ -281,7 +254,6 @@ vi.mock('../log-viewer', () => ({
),
}))
// Mock debounce
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: (...args: unknown[]) => unknown) => {
const debouncedFn = (...args: unknown[]) => fn(...args)
@ -290,10 +262,6 @@ vi.mock('es-toolkit/compat', () => ({
},
}))
// ============================================================================
// Test Suites
// ============================================================================
describe('CommonCreateModal', () => {
const defaultProps = {
onClose: vi.fn(),
@ -441,7 +409,8 @@ describe('CommonCreateModal', () => {
})
it('should call onConfirm handler when confirm button is clicked', () => {
render(<CommonCreateModal {...defaultProps} />)
// Provide builder so the guard passes and credentials check is reached
render(<CommonCreateModal {...defaultProps} builder={createMockSubscriptionBuilder()} />)
fireEvent.click(screen.getByTestId('modal-confirm'))
@ -1243,13 +1212,22 @@ describe('CommonCreateModal', () => {
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
// Wait for createBuilder to complete and state to update
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
// Allow React to process the state update from createBuilder
await act(async () => {})
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
// Wait for updateBuilder to be called, then check the toast
await waitFor(() => {
expect(mockUpdateBuilder).toHaveBeenCalled()
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
@ -1450,7 +1428,8 @@ describe('CommonCreateModal', () => {
})
mockUsePluginStore.mockReturnValue(detailWithCredentials)
render(<CommonCreateModal {...defaultProps} />)
// Provide builder so the guard passes and credentials check is reached
render(<CommonCreateModal {...defaultProps} builder={createMockSubscriptionBuilder()} />)
fireEvent.click(screen.getByTestId('modal-confirm'))

View File

@ -1,32 +1,19 @@
'use client'
import type { FormRefObject } from '@/app/components/base/form/types'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers'
import { RiLoader2Line } from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
// import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
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 { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import {
useBuildTriggerSubscription,
useCreateTriggerSubscriptionBuilder,
useTriggerSubscriptionBuilderLogs,
useUpdateTriggerSubscriptionBuilder,
useVerifyAndUpdateTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
import { usePluginStore } from '../../store'
import LogViewer from '../log-viewer'
import { useSubscriptionList } from '../use-subscription-list'
ConfigurationStepContent,
MultiSteps,
VerifyStepContent,
} from './components/modal-steps'
import {
ApiKeyStep,
MODAL_TITLE_KEY_MAP,
useCommonModalState,
} from './hooks/use-common-modal-state'
type Props = {
onClose: () => void
@ -34,316 +21,33 @@ type Props = {
builder?: TriggerSubscriptionBuilder
}
const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = {
[SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey,
[SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2,
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
}
const MODAL_TITLE_KEY_MAP: Record<
SupportedCreationMethods,
'modal.apiKey.title' | 'modal.oauth.title' | 'modal.manual.title'
> = {
[SupportedCreationMethods.APIKEY]: 'modal.apiKey.title',
[SupportedCreationMethods.OAUTH]: 'modal.oauth.title',
[SupportedCreationMethods.MANUAL]: 'modal.manual.title',
}
enum ApiKeyStep {
Verify = 'verify',
Configuration = 'configuration',
}
const defaultFormValues = { values: {}, isCheckValidated: false }
const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => {
if (Object.values(FormTypeEnum).includes(type as FormTypeEnum))
return type as 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
default:
return FormTypeEnum.textInput
}
}
const StatusStep = ({ isActive, text }: { isActive: boolean, text: string }) => {
return (
<div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive
? 'text-state-accent-solid'
: 'text-text-tertiary'}`}
>
{/* Active indicator dot */}
{isActive && (
<div className="h-1 w-1 rounded-full bg-state-accent-solid"></div>
)}
{text}
</div>
)
}
const MultiSteps = ({ currentStep }: { currentStep: ApiKeyStep }) => {
const { t } = useTranslation()
return (
<div className="mb-6 flex w-1/3 items-center gap-2">
<StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('modal.steps.verify', { ns: 'pluginTrigger' })} />
<div className="h-px w-3 shrink-0 bg-divider-deep"></div>
<StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('modal.steps.configuration', { ns: 'pluginTrigger' })} />
</div>
)
}
export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const { refetch } = useSubscriptionList()
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration)
const {
currentStep,
subscriptionBuilder,
isVerifyingCredentials,
isBuilding,
formRefs,
detail,
manualPropertiesSchema,
autoCommonParametersSchema,
apiKeyCredentialsSchema,
logData,
confirmButtonText,
handleConfirm,
handleManualPropertiesChange,
handleApiKeyCredentialsChange,
} = useCommonModalState({
createType,
builder,
onClose,
})
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
const isInitializedRef = useRef(false)
const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder()
const { mutateAsync: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder()
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder()
const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] // manual
const manualPropertiesFormRef = React.useRef<FormRefObject>(null)
const subscriptionFormRef = React.useRef<FormRefObject>(null)
const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] // apikey and oauth
const autoCommonParametersFormRef = React.useRef<FormRefObject>(null)
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 apiKeyCredentialsFormRef = React.useRef<FormRefObject>(null)
const { data: logData } = useTriggerSubscriptionBuilderLogs(
detail?.provider || '',
subscriptionBuilder?.id || '',
{
enabled: createType === SupportedCreationMethods.MANUAL,
refetchInterval: 3000,
},
)
useEffect(() => {
const initializeBuilder = async () => {
isInitializedRef.current = true
try {
const response = await createBuilder({
provider: detail?.provider || '',
credential_type: CREDENTIAL_TYPE_MAP[createType],
})
setSubscriptionBuilder(response.subscription_builder)
}
catch (error) {
console.error('createBuilder error:', error)
Toast.notify({
type: 'error',
message: t('modal.errors.createFailed', { ns: 'pluginTrigger' }),
})
}
}
if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider)
initializeBuilder()
}, [subscriptionBuilder, detail?.provider, createType, createBuilder, t])
useEffect(() => {
if (subscriptionBuilder?.endpoint && subscriptionFormRef.current && currentStep === ApiKeyStep.Configuration) {
const form = subscriptionFormRef.current.getForm()
if (form)
form.setFieldValue('callback_url', subscriptionBuilder.endpoint)
if (isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) {
console.warn('callback_url is private or local address', subscriptionBuilder.endpoint)
subscriptionFormRef.current?.setFields([{
name: 'callback_url',
warnings: [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })],
}])
}
else {
subscriptionFormRef.current?.setFields([{
name: 'callback_url',
warnings: [],
}])
}
}
}, [subscriptionBuilder?.endpoint, currentStep, t])
const debouncedUpdate = useMemo(
() => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => {
updateBuilder(
{
provider,
subscriptionBuilderId: builderId,
properties,
},
{
onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('modal.errors.updateFailed', { ns: 'pluginTrigger' })
console.error('Failed to update subscription builder:', error)
Toast.notify({
type: 'error',
message: errorMessage,
})
},
},
)
}, 500),
[updateBuilder, t],
)
const handleManualPropertiesChange = useCallback(() => {
if (!subscriptionBuilder || !detail?.provider)
return
const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false }) || { values: {}, isCheckValidated: true }
debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values)
}, [subscriptionBuilder, detail?.provider, debouncedUpdate])
useEffect(() => {
return () => {
debouncedUpdate.cancel()
}
}, [debouncedUpdate])
const handleVerify = () => {
const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || defaultFormValues
const credentials = apiKeyCredentialsFormValues.values
if (!Object.keys(credentials).length) {
Toast.notify({
type: 'error',
message: 'Please fill in all required credentials',
})
return
}
apiKeyCredentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
errors: [],
}])
verifyCredentials(
{
provider: detail?.provider || '',
subscriptionBuilderId: subscriptionBuilder?.id || '',
credentials,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('modal.apiKey.verify.success', { ns: 'pluginTrigger' }),
})
setCurrentStep(ApiKeyStep.Configuration)
},
onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' })
apiKeyCredentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
errors: [errorMessage],
}])
},
},
)
}
const handleCreate = () => {
if (!subscriptionBuilder) {
Toast.notify({
type: 'error',
message: 'Subscription builder not found',
})
return
}
const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({})
if (!subscriptionFormValues?.isCheckValidated)
return
const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string
const params: BuildTriggerSubscriptionPayload = {
provider: detail?.provider || '',
subscriptionBuilderId: subscriptionBuilder.id,
name: subscriptionNameValue,
}
if (createType !== SupportedCreationMethods.MANUAL) {
if (autoCommonParametersSchema.length > 0) {
const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || defaultFormValues
if (!autoCommonParametersFormValues?.isCheckValidated)
return
params.parameters = autoCommonParametersFormValues.values
}
}
else if (manualPropertiesSchema.length > 0) {
const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || defaultFormValues
if (!manualFormValues?.isCheckValidated)
return
}
buildSubscription(
params,
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('subscription.createSuccess', { ns: 'pluginTrigger' }),
})
onClose()
refetch?.()
},
onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('subscription.createFailed', { ns: 'pluginTrigger' })
Toast.notify({
type: 'error',
message: errorMessage,
})
},
},
)
}
const handleConfirm = () => {
if (currentStep === ApiKeyStep.Verify)
handleVerify()
else
handleCreate()
}
const handleApiKeyCredentialsChange = () => {
apiKeyCredentialsFormRef.current?.setFields([{
name: apiKeyCredentialsSchema[0].name,
errors: [],
}])
}
const confirmButtonText = useMemo(() => {
if (currentStep === ApiKeyStep.Verify)
return isVerifyingCredentials ? t('modal.common.verifying', { ns: 'pluginTrigger' }) : t('modal.common.verify', { ns: 'pluginTrigger' })
return isBuilding ? t('modal.common.creating', { ns: 'pluginTrigger' }) : t('modal.common.create', { ns: 'pluginTrigger' })
}, [currentStep, isVerifyingCredentials, isBuilding, t])
const isApiKeyType = createType === SupportedCreationMethods.APIKEY
const isVerifyStep = currentStep === ApiKeyStep.Verify
const isConfigurationStep = currentStep === ApiKeyStep.Configuration
return (
<Modal
@ -353,121 +57,36 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isVerifyingCredentials || isBuilding}
bottomSlot={currentStep === ApiKeyStep.Verify ? <EncryptedBottom /> : null}
bottomSlot={isVerifyStep ? <EncryptedBottom /> : null}
size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'}
containerClassName="min-h-[360px]"
clickOutsideNotClose
>
{createType === SupportedCreationMethods.APIKEY && <MultiSteps currentStep={currentStep} />}
{currentStep === ApiKeyStep.Verify && (
<>
{apiKeyCredentialsSchema.length > 0 && (
<div className="mb-4">
<BaseForm
formSchemas={apiKeyCredentialsSchema}
ref={apiKeyCredentialsFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
preventDefaultSubmit={true}
formClassName="space-y-4"
onChange={handleApiKeyCredentialsChange}
/>
</div>
)}
</>
)}
{currentStep === ApiKeyStep.Configuration && (
<div className="max-h-[70vh]">
<BaseForm
formSchemas={[
{
name: 'subscription_name',
label: t('modal.form.subscriptionName.label', { ns: 'pluginTrigger' }),
placeholder: t('modal.form.subscriptionName.placeholder', { ns: 'pluginTrigger' }),
type: FormTypeEnum.textInput,
required: true,
},
{
name: 'callback_url',
label: t('modal.form.callbackUrl.label', { ns: 'pluginTrigger' }),
placeholder: t('modal.form.callbackUrl.placeholder', { ns: 'pluginTrigger' }),
type: FormTypeEnum.textInput,
required: false,
default: subscriptionBuilder?.endpoint || '',
disabled: true,
tooltip: t('modal.form.callbackUrl.tooltip', { ns: 'pluginTrigger' }),
showCopy: true,
},
]}
ref={subscriptionFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4 mb-4"
/>
{/* <div className='system-xs-regular mb-6 mt-[-1rem] text-text-tertiary'>
{t('pluginTrigger.modal.form.callbackUrl.description')}
</div> */}
{createType !== SupportedCreationMethods.MANUAL && autoCommonParametersSchema.length > 0 && (
<BaseForm
formSchemas={autoCommonParametersSchema.map((schema) => {
const normalizedType = normalizeFormType(schema.type as FormTypeEnum | string)
return {
...schema,
tooltip: schema.description,
type: normalizedType,
dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect
? {
plugin_id: detail?.plugin_id || '',
provider: detail?.provider || '',
action: 'provider',
parameter: schema.name,
credential_id: subscriptionBuilder?.id || '',
}
: undefined,
fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined,
labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined,
}
})}
ref={autoCommonParametersFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
)}
{createType === SupportedCreationMethods.MANUAL && (
<>
{manualPropertiesSchema.length > 0 && (
<div className="mb-6">
<BaseForm
formSchemas={manualPropertiesSchema.map(schema => ({
...schema,
tooltip: schema.description,
}))}
ref={manualPropertiesFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
onChange={handleManualPropertiesChange}
/>
</div>
)}
<div className="mb-6">
<div className="mb-3 flex items-center gap-2">
<div className="system-xs-medium-uppercase text-text-tertiary">
{t('modal.manual.logs.title', { ns: 'pluginTrigger' })}
</div>
<div className="h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent" />
</div>
{isApiKeyType && <MultiSteps currentStep={currentStep} />}
<div className="mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3">
<div className="h-3.5 w-3.5">
<RiLoader2Line className="h-full w-full animate-spin" />
</div>
<div className="system-xs-regular text-text-tertiary">
{t('modal.manual.logs.loading', { ns: 'pluginTrigger', pluginName: detail?.name || '' })}
</div>
</div>
<LogViewer logs={logData?.logs || []} />
</div>
</>
)}
</div>
{isVerifyStep && (
<VerifyStepContent
apiKeyCredentialsSchema={apiKeyCredentialsSchema}
apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef}
onChange={handleApiKeyCredentialsChange}
/>
)}
{isConfigurationStep && (
<ConfigurationStepContent
createType={createType}
subscriptionBuilder={subscriptionBuilder}
subscriptionFormRef={formRefs.subscriptionFormRef}
autoCommonParametersSchema={autoCommonParametersSchema}
autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef}
manualPropertiesSchema={manualPropertiesSchema}
manualPropertiesFormRef={formRefs.manualPropertiesFormRef}
onManualPropertiesChange={handleManualPropertiesChange}
logs={logData?.logs || []}
pluginId={detail?.plugin_id || ''}
pluginName={detail?.name || ''}
provider={detail?.provider || ''}
/>
)}
</Modal>
)

View File

@ -0,0 +1,304 @@
'use client'
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { RiLoader2Line } from '@remixicon/react'
import * as React 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 { SupportedCreationMethods } from '@/app/components/plugins/types'
import LogViewer from '../../log-viewer'
import { ApiKeyStep } from '../hooks/use-common-modal-state'
export type SchemaItem = Partial<FormSchema> & Record<string, unknown> & {
name: string
}
type StatusStepProps = {
isActive: boolean
text: string
}
export const StatusStep = ({ isActive, text }: StatusStepProps) => {
return (
<div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive
? 'text-state-accent-solid'
: 'text-text-tertiary'}`}
>
{isActive && (
<div className="h-1 w-1 rounded-full bg-state-accent-solid"></div>
)}
{text}
</div>
)
}
type MultiStepsProps = {
currentStep: ApiKeyStep
}
export const MultiSteps = ({ currentStep }: MultiStepsProps) => {
const { t } = useTranslation()
return (
<div className="mb-6 flex w-1/3 items-center gap-2">
<StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('modal.steps.verify', { ns: 'pluginTrigger' })} />
<div className="h-px w-3 shrink-0 bg-divider-deep"></div>
<StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('modal.steps.configuration', { ns: 'pluginTrigger' })} />
</div>
)
}
type VerifyStepContentProps = {
apiKeyCredentialsSchema: SchemaItem[]
apiKeyCredentialsFormRef: React.RefObject<FormRefObject | null>
onChange: () => void
}
export const VerifyStepContent = ({
apiKeyCredentialsSchema,
apiKeyCredentialsFormRef,
onChange,
}: VerifyStepContentProps) => {
if (!apiKeyCredentialsSchema.length)
return null
return (
<div className="mb-4">
<BaseForm
formSchemas={apiKeyCredentialsSchema as FormSchema[]}
ref={apiKeyCredentialsFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
preventDefaultSubmit={true}
formClassName="space-y-4"
onChange={onChange}
/>
</div>
)
}
type SubscriptionFormProps = {
subscriptionFormRef: React.RefObject<FormRefObject | null>
endpoint?: string
}
export const SubscriptionForm = ({
subscriptionFormRef,
endpoint,
}: SubscriptionFormProps) => {
const { t } = useTranslation()
const formSchemas = React.useMemo(() => [
{
name: 'subscription_name',
label: t('modal.form.subscriptionName.label', { ns: 'pluginTrigger' }),
placeholder: t('modal.form.subscriptionName.placeholder', { ns: 'pluginTrigger' }),
type: FormTypeEnum.textInput,
required: true,
},
{
name: 'callback_url',
label: t('modal.form.callbackUrl.label', { ns: 'pluginTrigger' }),
placeholder: t('modal.form.callbackUrl.placeholder', { ns: 'pluginTrigger' }),
type: FormTypeEnum.textInput,
required: false,
default: endpoint || '',
disabled: true,
tooltip: t('modal.form.callbackUrl.tooltip', { ns: 'pluginTrigger' }),
showCopy: true,
},
], [endpoint, t])
return (
<BaseForm
formSchemas={formSchemas}
ref={subscriptionFormRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4 mb-4"
/>
)
}
const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => {
if (Object.values(FormTypeEnum).includes(type as FormTypeEnum))
return type as FormTypeEnum
const TYPE_MAP: Record<string, FormTypeEnum> = {
string: FormTypeEnum.textInput,
text: FormTypeEnum.textInput,
password: FormTypeEnum.secretInput,
secret: FormTypeEnum.secretInput,
number: FormTypeEnum.textNumber,
integer: FormTypeEnum.textNumber,
boolean: FormTypeEnum.boolean,
}
return TYPE_MAP[type] || FormTypeEnum.textInput
}
type AutoParametersFormProps = {
schemas: SchemaItem[]
formRef: React.RefObject<FormRefObject | null>
pluginId: string
provider: string
credentialId: string
}
export const AutoParametersForm = ({
schemas,
formRef,
pluginId,
provider,
credentialId,
}: AutoParametersFormProps) => {
const formSchemas = React.useMemo(() =>
schemas.map((schema) => {
const normalizedType = normalizeFormType((schema.type || FormTypeEnum.textInput) as FormTypeEnum | string)
return {
...schema,
tooltip: schema.description,
type: normalizedType,
dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect
? {
plugin_id: pluginId,
provider,
action: 'provider',
parameter: schema.name,
credential_id: credentialId,
}
: undefined,
fieldClassName: normalizedType === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined,
labelClassName: normalizedType === FormTypeEnum.boolean ? 'mb-0' : undefined,
}
}) as FormSchema[], [schemas, pluginId, provider, credentialId])
if (!schemas.length)
return null
return (
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
/>
)
}
type ManualPropertiesSectionProps = {
schemas: SchemaItem[]
formRef: React.RefObject<FormRefObject | null>
onChange: () => void
logs: TriggerLogEntity[]
pluginName: string
}
export const ManualPropertiesSection = ({
schemas,
formRef,
onChange,
logs,
pluginName,
}: ManualPropertiesSectionProps) => {
const { t } = useTranslation()
const formSchemas = React.useMemo(() =>
schemas.map(schema => ({
...schema,
tooltip: schema.description,
})) as FormSchema[], [schemas])
return (
<>
{schemas.length > 0 && (
<div className="mb-6">
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary"
formClassName="space-y-4"
onChange={onChange}
/>
</div>
)}
<div className="mb-6">
<div className="mb-3 flex items-center gap-2">
<div className="system-xs-medium-uppercase text-text-tertiary">
{t('modal.manual.logs.title', { ns: 'pluginTrigger' })}
</div>
<div className="h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent" />
</div>
<div className="mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3">
<div className="h-3.5 w-3.5">
<RiLoader2Line className="h-full w-full animate-spin" />
</div>
<div className="system-xs-regular text-text-tertiary">
{t('modal.manual.logs.loading', { ns: 'pluginTrigger', pluginName })}
</div>
</div>
<LogViewer logs={logs} />
</div>
</>
)
}
type ConfigurationStepContentProps = {
createType: SupportedCreationMethods
subscriptionBuilder?: TriggerSubscriptionBuilder
subscriptionFormRef: React.RefObject<FormRefObject | null>
autoCommonParametersSchema: SchemaItem[]
autoCommonParametersFormRef: React.RefObject<FormRefObject | null>
manualPropertiesSchema: SchemaItem[]
manualPropertiesFormRef: React.RefObject<FormRefObject | null>
onManualPropertiesChange: () => void
logs: TriggerLogEntity[]
pluginId: string
pluginName: string
provider: string
}
export const ConfigurationStepContent = ({
createType,
subscriptionBuilder,
subscriptionFormRef,
autoCommonParametersSchema,
autoCommonParametersFormRef,
manualPropertiesSchema,
manualPropertiesFormRef,
onManualPropertiesChange,
logs,
pluginId,
pluginName,
provider,
}: ConfigurationStepContentProps) => {
const isManualType = createType === SupportedCreationMethods.MANUAL
return (
<div className="max-h-[70vh]">
<SubscriptionForm
subscriptionFormRef={subscriptionFormRef}
endpoint={subscriptionBuilder?.endpoint}
/>
{!isManualType && autoCommonParametersSchema.length > 0 && (
<AutoParametersForm
schemas={autoCommonParametersSchema}
formRef={autoCommonParametersFormRef}
pluginId={pluginId}
provider={provider}
credentialId={subscriptionBuilder?.id || ''}
/>
)}
{isManualType && (
<ManualPropertiesSection
schemas={manualPropertiesSchema}
formRef={manualPropertiesFormRef}
onChange={onManualPropertiesChange}
logs={logs}
pluginName={pluginName}
/>
)}
</div>
)
}

View File

@ -0,0 +1,401 @@
'use client'
import type { SimpleDetail } from '../../../store'
import type { SchemaItem } from '../components/modal-steps'
import type { FormRefObject } from '@/app/components/base/form/types'
import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers'
import { debounce } from 'es-toolkit/compat'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import {
useBuildTriggerSubscription,
useCreateTriggerSubscriptionBuilder,
useTriggerSubscriptionBuilderLogs,
useUpdateTriggerSubscriptionBuilder,
useVerifyAndUpdateTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
import { usePluginStore } from '../../../store'
import { useSubscriptionList } from '../../use-subscription-list'
// ============================================================================
// Types
// ============================================================================
export enum ApiKeyStep {
Verify = 'verify',
Configuration = 'configuration',
}
export const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = {
[SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey,
[SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2,
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
}
export const MODAL_TITLE_KEY_MAP: Record<
SupportedCreationMethods,
'modal.apiKey.title' | 'modal.oauth.title' | 'modal.manual.title'
> = {
[SupportedCreationMethods.APIKEY]: 'modal.apiKey.title',
[SupportedCreationMethods.OAUTH]: 'modal.oauth.title',
[SupportedCreationMethods.MANUAL]: 'modal.manual.title',
}
type UseCommonModalStateParams = {
createType: SupportedCreationMethods
builder?: TriggerSubscriptionBuilder
onClose: () => void
}
type FormRefs = {
manualPropertiesFormRef: React.RefObject<FormRefObject | null>
subscriptionFormRef: React.RefObject<FormRefObject | null>
autoCommonParametersFormRef: React.RefObject<FormRefObject | null>
apiKeyCredentialsFormRef: React.RefObject<FormRefObject | null>
}
type UseCommonModalStateReturn = {
// State
currentStep: ApiKeyStep
subscriptionBuilder: TriggerSubscriptionBuilder | undefined
isVerifyingCredentials: boolean
isBuilding: boolean
// Form refs
formRefs: FormRefs
// Computed values
detail: SimpleDetail | undefined
manualPropertiesSchema: SchemaItem[]
autoCommonParametersSchema: SchemaItem[]
apiKeyCredentialsSchema: SchemaItem[]
logData: { logs: TriggerLogEntity[] } | undefined
confirmButtonText: string
// Handlers
handleVerify: () => void
handleCreate: () => void
handleConfirm: () => void
handleManualPropertiesChange: () => void
handleApiKeyCredentialsChange: () => void
}
const DEFAULT_FORM_VALUES = { values: {}, isCheckValidated: false }
// ============================================================================
// Hook Implementation
// ============================================================================
export const useCommonModalState = ({
createType,
builder,
onClose,
}: UseCommonModalStateParams): UseCommonModalStateReturn => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const { refetch } = useSubscriptionList()
// State
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(
createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration,
)
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder)
const isInitializedRef = useRef(false)
// Form refs
const manualPropertiesFormRef = useRef<FormRefObject>(null)
const subscriptionFormRef = useRef<FormRefObject>(null)
const autoCommonParametersFormRef = useRef<FormRefObject>(null)
const apiKeyCredentialsFormRef = useRef<FormRefObject>(null)
// Mutations
const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder()
const { mutateAsync: createBuilder } = useCreateTriggerSubscriptionBuilder()
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder()
// Schemas
const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || []
const autoCommonParametersSchema = 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])
// Log data for manual mode
const { data: logData } = useTriggerSubscriptionBuilderLogs(
detail?.provider || '',
subscriptionBuilder?.id || '',
{
enabled: createType === SupportedCreationMethods.MANUAL,
refetchInterval: 3000,
},
)
// Debounced update for manual properties
const debouncedUpdate = useMemo(
() => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => {
updateBuilder(
{
provider,
subscriptionBuilderId: builderId,
properties,
},
{
onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('modal.errors.updateFailed', { ns: 'pluginTrigger' })
console.error('Failed to update subscription builder:', error)
Toast.notify({
type: 'error',
message: errorMessage,
})
},
},
)
}, 500),
[updateBuilder, t],
)
// Initialize builder
useEffect(() => {
const initializeBuilder = async () => {
isInitializedRef.current = true
try {
const response = await createBuilder({
provider: detail?.provider || '',
credential_type: CREDENTIAL_TYPE_MAP[createType],
})
setSubscriptionBuilder(response.subscription_builder)
}
catch (error) {
console.error('createBuilder error:', error)
Toast.notify({
type: 'error',
message: t('modal.errors.createFailed', { ns: 'pluginTrigger' }),
})
}
}
if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider)
initializeBuilder()
}, [subscriptionBuilder, detail?.provider, createType, createBuilder, t])
// Cleanup debounced function
useEffect(() => {
return () => {
debouncedUpdate.cancel()
}
}, [debouncedUpdate])
// Update endpoint in form when endpoint changes
useEffect(() => {
if (!subscriptionBuilder?.endpoint || !subscriptionFormRef.current || currentStep !== ApiKeyStep.Configuration)
return
const form = subscriptionFormRef.current.getForm()
if (form)
form.setFieldValue('callback_url', subscriptionBuilder.endpoint)
const warnings = isPrivateOrLocalAddress(subscriptionBuilder.endpoint)
? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })]
: []
subscriptionFormRef.current?.setFields([{
name: 'callback_url',
warnings,
}])
}, [subscriptionBuilder?.endpoint, currentStep, t])
// Handle manual properties change
const handleManualPropertiesChange = useCallback(() => {
if (!subscriptionBuilder || !detail?.provider)
return
const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false })
|| { values: {}, isCheckValidated: true }
debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values)
}, [subscriptionBuilder, detail?.provider, debouncedUpdate])
// Handle API key credentials change
const handleApiKeyCredentialsChange = useCallback(() => {
if (!apiKeyCredentialsSchema.length)
return
apiKeyCredentialsFormRef.current?.setFields([{
name: apiKeyCredentialsSchema[0].name,
errors: [],
}])
}, [apiKeyCredentialsSchema])
// Handle verify
const handleVerify = useCallback(() => {
// Guard against uninitialized state
if (!detail?.provider || !subscriptionBuilder?.id) {
Toast.notify({
type: 'error',
message: 'Subscription builder not initialized',
})
return
}
const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
const credentials = apiKeyCredentialsFormValues.values
if (!Object.keys(credentials).length) {
Toast.notify({
type: 'error',
message: 'Please fill in all required credentials',
})
return
}
apiKeyCredentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
errors: [],
}])
verifyCredentials(
{
provider: detail.provider,
subscriptionBuilderId: subscriptionBuilder.id,
credentials,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('modal.apiKey.verify.success', { ns: 'pluginTrigger' }),
})
setCurrentStep(ApiKeyStep.Configuration)
},
onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' })
apiKeyCredentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
errors: [errorMessage],
}])
},
},
)
}, [detail?.provider, subscriptionBuilder?.id, verifyCredentials, t])
// Handle create
const handleCreate = useCallback(() => {
if (!subscriptionBuilder) {
Toast.notify({
type: 'error',
message: 'Subscription builder not found',
})
return
}
const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({})
if (!subscriptionFormValues?.isCheckValidated)
return
const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string
const params: BuildTriggerSubscriptionPayload = {
provider: detail?.provider || '',
subscriptionBuilderId: subscriptionBuilder.id,
name: subscriptionNameValue,
}
if (createType !== SupportedCreationMethods.MANUAL) {
if (autoCommonParametersSchema.length > 0) {
const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
if (!autoCommonParametersFormValues?.isCheckValidated)
return
params.parameters = autoCommonParametersFormValues.values
}
}
else if (manualPropertiesSchema.length > 0) {
const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES
if (!manualFormValues?.isCheckValidated)
return
}
buildSubscription(
params,
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('subscription.createSuccess', { ns: 'pluginTrigger' }),
})
onClose()
refetch?.()
},
onError: async (error: unknown) => {
const errorMessage = await parsePluginErrorMessage(error) || t('subscription.createFailed', { ns: 'pluginTrigger' })
Toast.notify({
type: 'error',
message: errorMessage,
})
},
},
)
}, [
subscriptionBuilder,
detail?.provider,
createType,
autoCommonParametersSchema.length,
manualPropertiesSchema.length,
buildSubscription,
onClose,
refetch,
t,
])
// Handle confirm (dispatch based on step)
const handleConfirm = useCallback(() => {
if (currentStep === ApiKeyStep.Verify)
handleVerify()
else
handleCreate()
}, [currentStep, handleVerify, handleCreate])
// Confirm button text
const confirmButtonText = useMemo(() => {
if (currentStep === ApiKeyStep.Verify) {
return isVerifyingCredentials
? t('modal.common.verifying', { ns: 'pluginTrigger' })
: t('modal.common.verify', { ns: 'pluginTrigger' })
}
return isBuilding
? t('modal.common.creating', { ns: 'pluginTrigger' })
: t('modal.common.create', { ns: 'pluginTrigger' })
}, [currentStep, isVerifyingCredentials, isBuilding, t])
return {
currentStep,
subscriptionBuilder,
isVerifyingCredentials,
isBuilding,
formRefs: {
manualPropertiesFormRef,
subscriptionFormRef,
autoCommonParametersFormRef,
apiKeyCredentialsFormRef,
},
detail,
manualPropertiesSchema,
autoCommonParametersSchema,
apiKeyCredentialsSchema,
logData,
confirmButtonText,
handleVerify,
handleCreate,
handleConfirm,
handleManualPropertiesChange,
handleApiKeyCredentialsChange,
}
}

View File

@ -0,0 +1,719 @@
import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import {
AuthorizationStatusEnum,
ClientTypeEnum,
getErrorMessage,
useOAuthClientState,
} from './use-oauth-client-state'
// ============================================================================
// Mock Factory Functions
// ============================================================================
function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig {
return {
configured: true,
custom_configured: false,
custom_enabled: false,
system_configured: true,
redirect_uri: 'https://example.com/oauth/callback',
params: {
client_id: 'default-client-id',
client_secret: 'default-client-secret',
},
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
{ name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
...overrides,
}
}
function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBuilder> = {}): TriggerSubscriptionBuilder {
return {
id: 'builder-123',
name: 'Test Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credentials: {},
endpoint: 'https://example.com/callback',
parameters: {},
properties: {},
workflows_in_use: 0,
...overrides,
}
}
// ============================================================================
// Mock Setup
// ============================================================================
const mockInitiateOAuth = vi.fn()
const mockVerifyBuilder = vi.fn()
const mockConfigureOAuth = vi.fn()
const mockDeleteOAuth = vi.fn()
vi.mock('@/service/use-triggers', () => ({
useInitiateTriggerOAuth: () => ({
mutate: mockInitiateOAuth,
}),
useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({
mutate: mockVerifyBuilder,
}),
useConfigureTriggerOAuth: () => ({
mutate: mockConfigureOAuth,
}),
useDeleteTriggerOAuth: () => ({
mutate: mockDeleteOAuth,
}),
}))
const mockOpenOAuthPopup = vi.fn()
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback),
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (params: unknown) => mockToastNotify(params),
},
}))
// ============================================================================
// Test Suites
// ============================================================================
describe('getErrorMessage', () => {
it('should extract message from Error instance', () => {
const error = new Error('Test error message')
expect(getErrorMessage(error, 'fallback')).toBe('Test error message')
})
it('should extract message from object with message property', () => {
const error = { message: 'Object error message' }
expect(getErrorMessage(error, 'fallback')).toBe('Object error message')
})
it('should return fallback when error is empty object', () => {
expect(getErrorMessage({}, 'fallback')).toBe('fallback')
})
it('should return fallback when error.message is not a string', () => {
expect(getErrorMessage({ message: 123 }, 'fallback')).toBe('fallback')
})
it('should return fallback when error.message is empty string', () => {
expect(getErrorMessage({ message: '' }, 'fallback')).toBe('fallback')
})
it('should return fallback when error is null', () => {
expect(getErrorMessage(null, 'fallback')).toBe('fallback')
})
it('should return fallback when error is undefined', () => {
expect(getErrorMessage(undefined, 'fallback')).toBe('fallback')
})
it('should return fallback when error is a primitive', () => {
expect(getErrorMessage('string error', 'fallback')).toBe('fallback')
expect(getErrorMessage(123, 'fallback')).toBe('fallback')
})
})
describe('useOAuthClientState', () => {
const defaultParams = {
oauthConfig: createMockOAuthConfig(),
providerName: 'test-provider',
onClose: vi.fn(),
showOAuthCreateModal: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('Initial State', () => {
it('should default to Default client type when system_configured is true', () => {
const { result } = renderHook(() => useOAuthClientState(defaultParams))
expect(result.current.clientType).toBe(ClientTypeEnum.Default)
})
it('should default to Custom client type when system_configured is false', () => {
const config = createMockOAuthConfig({ system_configured: false })
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
oauthConfig: config,
}))
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
})
it('should have undefined authorizationStatus initially', () => {
const { result } = renderHook(() => useOAuthClientState(defaultParams))
expect(result.current.authorizationStatus).toBeUndefined()
})
it('should provide clientFormRef', () => {
const { result } = renderHook(() => useOAuthClientState(defaultParams))
expect(result.current.clientFormRef).toBeDefined()
expect(result.current.clientFormRef.current).toBeNull()
})
})
describe('OAuth Client Schema', () => {
it('should compute schema with default values from params', () => {
const config = createMockOAuthConfig({
params: {
client_id: 'my-client-id',
client_secret: 'my-secret',
},
})
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
oauthConfig: config,
}))
expect(result.current.oauthClientSchema).toHaveLength(2)
expect(result.current.oauthClientSchema[0].default).toBe('my-client-id')
expect(result.current.oauthClientSchema[1].default).toBe('my-secret')
})
it('should return empty array when oauth_client_schema is empty', () => {
const config = createMockOAuthConfig({
oauth_client_schema: [],
})
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
oauthConfig: config,
}))
expect(result.current.oauthClientSchema).toEqual([])
})
it('should return empty array when params is undefined', () => {
const config = createMockOAuthConfig({
params: undefined as unknown as TriggerOAuthConfig['params'],
})
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
oauthConfig: config,
}))
expect(result.current.oauthClientSchema).toEqual([])
})
it('should preserve original schema default when param key not found', () => {
const config = createMockOAuthConfig({
params: {
client_id: 'only-client-id',
client_secret: '', // empty
},
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: {} as unknown, default: 'original-default' },
{ name: 'extra_field', type: 'text-input' as unknown, required: false, label: {} as unknown, default: 'extra-default' },
] as TriggerOAuthConfig['oauth_client_schema'],
})
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
oauthConfig: config,
}))
// client_id should be overridden
expect(result.current.oauthClientSchema[0].default).toBe('only-client-id')
// extra_field should keep original default since key not in params
expect(result.current.oauthClientSchema[1].default).toBe('extra-default')
})
})
describe('Confirm Button Text', () => {
it('should show saveAndAuth text by default', () => {
const { result } = renderHook(() => useOAuthClientState(defaultParams))
expect(result.current.confirmButtonText).toBe('plugin.auth.saveAndAuth')
})
it('should show authorizing text when status is Pending', async () => {
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation(() => {
// Don't resolve - stays pending
})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(true)
})
await waitFor(() => {
expect(result.current.confirmButtonText).toBe('pluginTrigger.modal.common.authorizing')
})
})
})
describe('setClientType', () => {
it('should update client type when called', () => {
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.setClientType(ClientTypeEnum.Custom)
})
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
})
it('should toggle between client types', () => {
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.setClientType(ClientTypeEnum.Custom)
})
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
act(() => {
result.current.setClientType(ClientTypeEnum.Default)
})
expect(result.current.clientType).toBe(ClientTypeEnum.Default)
})
})
describe('handleRemove', () => {
it('should call deleteOAuth with provider name', () => {
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleRemove()
})
expect(mockDeleteOAuth).toHaveBeenCalledWith(
'test-provider',
expect.any(Object),
)
})
it('should call onClose and show success toast on success', () => {
mockDeleteOAuth.mockImplementation((provider, { onSuccess }) => onSuccess())
const onClose = vi.fn()
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
onClose,
}))
act(() => {
result.current.handleRemove()
})
expect(onClose).toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'pluginTrigger.modal.oauth.remove.success',
})
})
it('should show error toast with error message on failure', () => {
mockDeleteOAuth.mockImplementation((provider, { onError }) => {
onError(new Error('Delete failed'))
})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleRemove()
})
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Delete failed',
})
})
})
describe('handleSave', () => {
it('should call configureOAuth with enabled: false for Default type', () => {
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(false)
})
expect(mockConfigureOAuth).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'test-provider',
enabled: false,
}),
expect.any(Object),
)
})
it('should call configureOAuth with enabled: true for Custom type', () => {
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
const config = createMockOAuthConfig({ system_configured: false })
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
oauthConfig: config,
}))
// Mock the form ref
const mockFormRef = {
getFormValues: () => ({
values: { client_id: 'new-id', client_secret: 'new-secret' },
isCheckValidated: true,
}),
}
// @ts-expect-error - mocking ref
result.current.clientFormRef.current = mockFormRef
act(() => {
result.current.handleSave(false)
})
expect(mockConfigureOAuth).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
}),
expect.any(Object),
)
})
it('should show success toast and call onClose when needAuth is false', () => {
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
const onClose = vi.fn()
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
onClose,
}))
act(() => {
result.current.handleSave(false)
})
expect(onClose).toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'pluginTrigger.modal.oauth.save.success',
})
})
it('should trigger authorization when needAuth is true', () => {
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
onSuccess({
authorization_url: 'https://oauth.example.com/authorize',
subscription_builder: createMockSubscriptionBuilder(),
})
})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(true)
})
expect(mockInitiateOAuth).toHaveBeenCalledWith(
'test-provider',
expect.any(Object),
)
})
})
describe('handleAuthorization', () => {
it('should set status to Pending and call initiateOAuth', () => {
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation(() => {})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(true)
})
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending)
expect(mockInitiateOAuth).toHaveBeenCalled()
})
it('should open OAuth popup on success', () => {
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
onSuccess({
authorization_url: 'https://oauth.example.com/authorize',
subscription_builder: createMockSubscriptionBuilder(),
})
})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(true)
})
expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
'https://oauth.example.com/authorize',
expect.any(Function),
)
})
it('should set status to Failed and show error toast on error', () => {
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation((provider, { onError }) => {
onError(new Error('OAuth failed'))
})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(true)
})
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Failed)
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'pluginTrigger.modal.oauth.authorization.authFailed',
})
})
it('should call onClose and showOAuthCreateModal on callback success', () => {
const onClose = vi.fn()
const showOAuthCreateModal = vi.fn()
const builder = createMockSubscriptionBuilder()
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
onSuccess({
authorization_url: 'https://oauth.example.com/authorize',
subscription_builder: builder,
})
})
mockOpenOAuthPopup.mockImplementation((url, callback) => {
callback({ success: true })
})
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
onClose,
showOAuthCreateModal,
}))
act(() => {
result.current.handleSave(true)
})
expect(onClose).toHaveBeenCalled()
expect(showOAuthCreateModal).toHaveBeenCalledWith(builder)
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'pluginTrigger.modal.oauth.authorization.authSuccess',
})
})
it('should not call callbacks when OAuth callback returns falsy', () => {
const onClose = vi.fn()
const showOAuthCreateModal = vi.fn()
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
onSuccess({
authorization_url: 'https://oauth.example.com/authorize',
subscription_builder: createMockSubscriptionBuilder(),
})
})
mockOpenOAuthPopup.mockImplementation((url, callback) => {
callback(null)
})
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
onClose,
showOAuthCreateModal,
}))
act(() => {
result.current.handleSave(true)
})
expect(onClose).not.toHaveBeenCalled()
expect(showOAuthCreateModal).not.toHaveBeenCalled()
})
})
describe('Polling Effect', () => {
it('should start polling after authorization starts', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
onSuccess({
authorization_url: 'https://oauth.example.com/authorize',
subscription_builder: createMockSubscriptionBuilder(),
})
})
mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
onSuccess({ verified: false })
})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(true)
})
// Advance timer to trigger first poll
await act(async () => {
vi.advanceTimersByTime(3000)
})
expect(mockVerifyBuilder).toHaveBeenCalled()
vi.useRealTimers()
})
it('should set status to Success when verified', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
onSuccess({
authorization_url: 'https://oauth.example.com/authorize',
subscription_builder: createMockSubscriptionBuilder(),
})
})
mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
onSuccess({ verified: true })
})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(true)
})
await act(async () => {
vi.advanceTimersByTime(3000)
})
await waitFor(() => {
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Success)
})
vi.useRealTimers()
})
it('should continue polling on error', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
onSuccess({
authorization_url: 'https://oauth.example.com/authorize',
subscription_builder: createMockSubscriptionBuilder(),
})
})
mockVerifyBuilder.mockImplementation((params, { onError }) => {
onError(new Error('Verify failed'))
})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(true)
})
await act(async () => {
vi.advanceTimersByTime(3000)
})
expect(mockVerifyBuilder).toHaveBeenCalled()
// Status should still be Pending
expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending)
vi.useRealTimers()
})
it('should stop polling when verified', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess())
mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
onSuccess({
authorization_url: 'https://oauth.example.com/authorize',
subscription_builder: createMockSubscriptionBuilder(),
})
})
mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
onSuccess({ verified: true })
})
const { result } = renderHook(() => useOAuthClientState(defaultParams))
act(() => {
result.current.handleSave(true)
})
// First poll - should verify
await act(async () => {
vi.advanceTimersByTime(3000)
})
expect(mockVerifyBuilder).toHaveBeenCalledTimes(1)
// Second poll - should not happen as interval is cleared
await act(async () => {
vi.advanceTimersByTime(3000)
})
// Still only 1 call because polling stopped
expect(mockVerifyBuilder).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
describe('Edge Cases', () => {
it('should handle undefined oauthConfig', () => {
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
oauthConfig: undefined,
}))
expect(result.current.clientType).toBe(ClientTypeEnum.Custom)
expect(result.current.oauthClientSchema).toEqual([])
})
it('should handle empty providerName', () => {
const { result } = renderHook(() => useOAuthClientState({
...defaultParams,
providerName: '',
}))
// Should not throw
expect(result.current.clientType).toBe(ClientTypeEnum.Default)
})
})
})
describe('Enum Exports', () => {
it('should export AuthorizationStatusEnum', () => {
expect(AuthorizationStatusEnum.Pending).toBe('pending')
expect(AuthorizationStatusEnum.Success).toBe('success')
expect(AuthorizationStatusEnum.Failed).toBe('failed')
})
it('should export ClientTypeEnum', () => {
expect(ClientTypeEnum.Default).toBe('default')
expect(ClientTypeEnum.Custom).toBe('custom')
})
})

View File

@ -0,0 +1,241 @@
'use client'
import type { FormRefObject } from '@/app/components/base/form/types'
import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { openOAuthPopup } from '@/hooks/use-oauth'
import {
useConfigureTriggerOAuth,
useDeleteTriggerOAuth,
useInitiateTriggerOAuth,
useVerifyAndUpdateTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
export enum AuthorizationStatusEnum {
Pending = 'pending',
Success = 'success',
Failed = 'failed',
}
export enum ClientTypeEnum {
Default = 'default',
Custom = 'custom',
}
const POLL_INTERVAL_MS = 3000
// Extract error message from various error formats
export const getErrorMessage = (error: unknown, fallback: string): 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
}
type UseOAuthClientStateParams = {
oauthConfig?: TriggerOAuthConfig
providerName: string
onClose: () => void
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
}
type UseOAuthClientStateReturn = {
// State
clientType: ClientTypeEnum
setClientType: (type: ClientTypeEnum) => void
authorizationStatus: AuthorizationStatusEnum | undefined
// Refs
clientFormRef: React.RefObject<FormRefObject | null>
// Computed values
oauthClientSchema: TriggerOAuthConfig['oauth_client_schema']
confirmButtonText: string
// Handlers
handleAuthorization: () => void
handleRemove: () => void
handleSave: (needAuth: boolean) => void
}
export const useOAuthClientState = ({
oauthConfig,
providerName,
onClose,
showOAuthCreateModal,
}: UseOAuthClientStateParams): UseOAuthClientStateReturn => {
const { t } = useTranslation()
// State management
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>()
const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>()
const [clientType, setClientType] = useState<ClientTypeEnum>(
oauthConfig?.system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom,
)
const clientFormRef = useRef<FormRefObject>(null)
// Mutations
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
const { mutate: verifyBuilder } = useVerifyAndUpdateTriggerSubscriptionBuilder()
const { mutate: configureOAuth } = useConfigureTriggerOAuth()
const { mutate: deleteOAuth } = useDeleteTriggerOAuth()
// Compute OAuth client schema with default values
const oauthClientSchema = useMemo(() => {
const { oauth_client_schema, params } = oauthConfig || {}
if (!oauth_client_schema?.length || !params)
return []
const paramKeys = Object.keys(params)
return oauth_client_schema.map(schema => ({
...schema,
default: paramKeys.includes(schema.name) ? params[schema.name] : schema.default,
}))
}, [oauthConfig])
// Compute confirm button text based on authorization status
const confirmButtonText = useMemo(() => {
if (authorizationStatus === AuthorizationStatusEnum.Pending)
return t('modal.common.authorizing', { ns: 'pluginTrigger' })
if (authorizationStatus === AuthorizationStatusEnum.Success)
return t('modal.oauth.authorization.waitingJump', { ns: 'pluginTrigger' })
return t('auth.saveAndAuth', { ns: 'plugin' })
}, [authorizationStatus, t])
// Authorization handler
const handleAuthorization = useCallback(() => {
setAuthorizationStatus(AuthorizationStatusEnum.Pending)
initiateOAuth(providerName, {
onSuccess: (response) => {
setSubscriptionBuilder(response.subscription_builder)
openOAuthPopup(response.authorization_url, (callbackData) => {
if (!callbackData)
return
Toast.notify({
type: 'success',
message: t('modal.oauth.authorization.authSuccess', { ns: 'pluginTrigger' }),
})
onClose()
showOAuthCreateModal(response.subscription_builder)
})
},
onError: () => {
setAuthorizationStatus(AuthorizationStatusEnum.Failed)
Toast.notify({
type: 'error',
message: t('modal.oauth.authorization.authFailed', { ns: 'pluginTrigger' }),
})
},
})
}, [providerName, initiateOAuth, onClose, showOAuthCreateModal, t])
// Remove handler
const handleRemove = useCallback(() => {
deleteOAuth(providerName, {
onSuccess: () => {
onClose()
Toast.notify({
type: 'success',
message: t('modal.oauth.remove.success', { ns: 'pluginTrigger' }),
})
},
onError: (error: unknown) => {
Toast.notify({
type: 'error',
message: getErrorMessage(error, t('modal.oauth.remove.failed', { ns: 'pluginTrigger' })),
})
},
})
}, [providerName, deleteOAuth, onClose, t])
// Save handler
const handleSave = useCallback((needAuth: boolean) => {
const isCustom = clientType === ClientTypeEnum.Custom
const params: ConfigureTriggerOAuthPayload = {
provider: providerName,
enabled: isCustom,
}
if (isCustom && oauthClientSchema?.length) {
const clientFormValues = clientFormRef.current?.getFormValues({}) as {
values: TriggerOAuthClientParams
isCheckValidated: boolean
} | undefined
// Handle missing ref or form values
if (!clientFormValues || !clientFormValues.isCheckValidated)
return
const clientParams = { ...clientFormValues.values }
// Preserve hidden values if unchanged
if (clientParams.client_id === oauthConfig?.params.client_id)
clientParams.client_id = '[__HIDDEN__]'
if (clientParams.client_secret === oauthConfig?.params.client_secret)
clientParams.client_secret = '[__HIDDEN__]'
params.client_params = clientParams
}
configureOAuth(params, {
onSuccess: () => {
if (needAuth) {
handleAuthorization()
return
}
onClose()
Toast.notify({
type: 'success',
message: t('modal.oauth.save.success', { ns: 'pluginTrigger' }),
})
},
})
}, [clientType, providerName, oauthClientSchema, oauthConfig?.params, configureOAuth, handleAuthorization, onClose, t])
// Polling effect for authorization verification
useEffect(() => {
const shouldPoll = providerName
&& subscriptionBuilder
&& authorizationStatus === AuthorizationStatusEnum.Pending
if (!shouldPoll)
return
const pollInterval = setInterval(() => {
verifyBuilder(
{
provider: providerName,
subscriptionBuilderId: subscriptionBuilder.id,
},
{
onSuccess: (response) => {
if (response.verified) {
setAuthorizationStatus(AuthorizationStatusEnum.Success)
clearInterval(pollInterval)
}
},
onError: () => {
// Continue polling on error - auth might still be in progress
},
},
)
}, POLL_INTERVAL_MS)
return () => clearInterval(pollInterval)
}, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName])
return {
clientType,
setClientType,
authorizationStatus,
clientFormRef,
oauthClientSchema,
confirmButtonText,
handleAuthorization,
handleRemove,
handleSave,
}
}

View File

@ -6,9 +6,6 @@ import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from './index'
// ==================== Mock Setup ====================
// Mock shared state for portal
let mockPortalOpenState = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
@ -36,21 +33,18 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
},
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock zustand store
let mockStoreDetail: SimpleDetail | undefined
vi.mock('../../store', () => ({
usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) =>
selector({ detail: mockStoreDetail }),
}))
// Mock subscription list hook
const mockSubscriptions: TriggerSubscription[] = []
const mockRefetch = vi.fn()
vi.mock('../use-subscription-list', () => ({
@ -60,7 +54,6 @@ vi.mock('../use-subscription-list', () => ({
}),
}))
// Mock trigger service hooks
let mockProviderInfo: { data: TriggerProviderApiEntity | undefined } = { data: undefined }
let mockOAuthConfig: { data: TriggerOAuthConfig | undefined, refetch: () => void } = { data: undefined, refetch: vi.fn() }
const mockInitiateOAuth = vi.fn()
@ -73,14 +66,12 @@ vi.mock('@/service/use-triggers', () => ({
}),
}))
// Mock OAuth popup
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: vi.fn((url: string, callback: (data?: unknown) => void) => {
callback({ success: true, subscriptionId: 'test-subscription' })
}),
}))
// Mock child modals
vi.mock('./common-modal', () => ({
CommonCreateModal: ({ createType, onClose, builder }: {
createType: SupportedCreationMethods
@ -128,7 +119,6 @@ vi.mock('./oauth-client', () => ({
),
}))
// Mock CustomSelect
vi.mock('@/app/components/base/select/custom', () => ({
default: ({ options, value, onChange, CustomTrigger, CustomOption, containerProps }: {
options: Array<{ value: string, label: string, show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
@ -160,11 +150,6 @@ vi.mock('@/app/components/base/select/custom', () => ({
),
}))
// ==================== Test Utilities ====================
/**
* Factory function to create a TriggerProviderApiEntity with defaults
*/
const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): TriggerProviderApiEntity => ({
author: 'test-author',
name: 'test-provider',
@ -179,9 +164,6 @@ const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}):
...overrides,
})
/**
* Factory function to create a TriggerOAuthConfig with defaults
*/
const createOAuthConfig = (overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig => ({
configured: false,
custom_configured: false,
@ -196,9 +178,6 @@ const createOAuthConfig = (overrides: Partial<TriggerOAuthConfig> = {}): Trigger
...overrides,
})
/**
* Factory function to create a SimpleDetail with defaults
*/
const createStoreDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({
plugin_id: 'test-plugin',
name: 'Test Plugin',
@ -209,9 +188,6 @@ const createStoreDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail
...overrides,
})
/**
* Factory function to create a TriggerSubscription with defaults
*/
const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({
id: 'test-subscription',
name: 'Test Subscription',
@ -225,16 +201,10 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg
...overrides,
})
/**
* Factory function to create default props
*/
const createDefaultProps = (overrides: Partial<Parameters<typeof CreateSubscriptionButton>[0]> = {}) => ({
...overrides,
})
/**
* Helper to set up mock data for testing
*/
const setupMocks = (config: {
providerInfo?: TriggerProviderApiEntity
oauthConfig?: TriggerOAuthConfig
@ -249,8 +219,6 @@ const setupMocks = (config: {
mockSubscriptions.push(...config.subscriptions)
}
// ==================== Tests ====================
describe('CreateSubscriptionButton', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -258,7 +226,6 @@ describe('CreateSubscriptionButton', () => {
setupMocks()
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render null when supportedMethods is empty', () => {
// Arrange
@ -322,7 +289,6 @@ describe('CreateSubscriptionButton', () => {
})
})
// ==================== Props Testing ====================
describe('Props', () => {
it('should apply default buttonType as FULL_BUTTON', () => {
// Arrange
@ -355,7 +321,6 @@ describe('CreateSubscriptionButton', () => {
})
})
// ==================== State Management ====================
describe('State Management', () => {
it('should show CommonCreateModal when selectedCreateInfo is set', async () => {
// Arrange
@ -474,7 +439,6 @@ describe('CreateSubscriptionButton', () => {
})
})
// ==================== Memoization Logic ====================
describe('Memoization - buttonTextMap', () => {
it('should display correct button text for OAUTH method', () => {
// Arrange

View File

@ -2,7 +2,7 @@ import type { Option } from '@/app/components/base/select/custom'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { RiAddLine, RiEqualizer2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActionButton, ActionButtonState } from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
@ -18,11 +18,7 @@ import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
import { CommonCreateModal } from './common-modal'
import { OAuthClientSettingsModal } from './oauth-client'
export enum CreateButtonType {
FULL_BUTTON = 'full-button',
ICON_BUTTON = 'icon-button',
}
import { CreateButtonType, DEFAULT_METHOD } from './types'
type Props = {
className?: string
@ -32,8 +28,6 @@ type Props = {
const MAX_COUNT = 10
export const DEFAULT_METHOD = 'default'
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => {
const { t } = useTranslation()
const { subscriptions } = useSubscriptionList()
@ -43,7 +37,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
const detail = usePluginStore(state => state.detail)
const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '')
const supportedMethods = providerInfo?.supported_creation_methods || []
const supportedMethods = useMemo(() => providerInfo?.supported_creation_methods || [], [providerInfo?.supported_creation_methods])
const { data: oauthConfig, refetch: refetchOAuthConfig } = useTriggerOAuthConfig(detail?.provider || '', supportedMethods.includes(SupportedCreationMethods.OAUTH))
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
@ -63,11 +57,11 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
}
}, [t])
const onClickClientSettings = (e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => {
const onClickClientSettings = useCallback((e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => {
e.stopPropagation()
e.preventDefault()
showClientSettingsModal()
}
}, [showClientSettingsModal])
const allOptions = useMemo(() => {
const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured
@ -104,7 +98,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
show: supportedMethods.includes(SupportedCreationMethods.MANUAL),
},
]
}, [t, oauthConfig, supportedMethods, methodType])
}, [t, oauthConfig, supportedMethods, methodType, onClickClientSettings])
const onChooseCreateType = async (type: SupportedCreationMethods) => {
if (type === SupportedCreationMethods.OAUTH) {
@ -160,7 +154,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
<CustomSelect<Option & { show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
options={allOptions.filter(option => option.show)}
value={methodType}
onChange={value => onChooseCreateType(value as any)}
onChange={value => onChooseCreateType(value as SupportedCreationMethods)}
containerProps={{
open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false,
placement: 'bottom-start',
@ -254,3 +248,5 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
</>
)
}
export { CreateButtonType, DEFAULT_METHOD } from './types'

View File

@ -3,24 +3,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
// Import after mocks
import { OAuthClientSettingsModal } from './oauth-client'
// ============================================================================
// Type Definitions
// ============================================================================
type PluginDetail = {
plugin_id: string
provider: string
name: string
}
// ============================================================================
// Mock Factory Functions
// ============================================================================
function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig {
return {
configured: true,
@ -64,18 +54,12 @@ function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBui
}
}
// ============================================================================
// Mock Setup
// ============================================================================
// Mock plugin store
const mockPluginDetail = createMockPluginDetail()
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
vi.mock('../../store', () => ({
usePluginStore: () => mockUsePluginStore(),
}))
// Mock service hooks
const mockInitiateOAuth = vi.fn()
const mockVerifyBuilder = vi.fn()
const mockConfigureOAuth = vi.fn()
@ -96,13 +80,11 @@ vi.mock('@/service/use-triggers', () => ({
}),
}))
// Mock OAuth popup
const mockOpenOAuthPopup = vi.fn()
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback),
}))
// Mock toast
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
@ -110,7 +92,6 @@ vi.mock('@/app/components/base/toast', () => ({
},
}))
// Mock clipboard API
const mockClipboardWriteText = vi.fn()
Object.assign(navigator, {
clipboard: {
@ -118,7 +99,6 @@ Object.assign(navigator, {
},
})
// Mock Modal component
vi.mock('@/app/components/base/modal/modal', () => ({
default: ({
children,
@ -161,24 +141,6 @@ vi.mock('@/app/components/base/modal/modal', () => ({
),
}))
// Mock Button component
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, variant, className }: {
children: React.ReactNode
onClick?: () => void
variant?: string
className?: string
}) => (
<button
data-testid={`button-${variant || 'default'}`}
onClick={onClick}
className={className}
>
{children}
</button>
),
}))
// Configurable form mock values
let mockFormValues: { values: Record<string, string>, isCheckValidated: boolean } = {
values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
isCheckValidated: true,
@ -210,29 +172,6 @@ vi.mock('@/app/components/base/form/components/base', () => ({
}),
}))
// Mock OptionCard component
vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
default: ({ title, onSelect, selected, className }: {
title: string
onSelect: () => void
selected: boolean
className?: string
}) => (
<div
data-testid={`option-card-${title}`}
onClick={onSelect}
className={`${className} ${selected ? 'selected' : ''}`}
data-selected={selected}
>
{title}
</div>
),
}))
// ============================================================================
// Test Suites
// ============================================================================
describe('OAuthClientSettingsModal', () => {
const defaultProps = {
oauthConfig: createMockOAuthConfig(),
@ -244,7 +183,6 @@ describe('OAuthClientSettingsModal', () => {
vi.clearAllMocks()
mockUsePluginStore.mockReturnValue(mockPluginDetail)
mockClipboardWriteText.mockResolvedValue(undefined)
// Reset form values to default
setMockFormValues({
values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
isCheckValidated: true,
@ -265,8 +203,8 @@ describe('OAuthClientSettingsModal', () => {
it('should render client type selector when system_configured is true', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument()
expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument()
expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument()
expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument()
})
it('should not render client type selector when system_configured is false', () => {
@ -276,7 +214,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithoutSystemConfigured} />)
expect(screen.queryByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument()
expect(screen.queryByText('pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument()
})
it('should render redirect URI info when custom client type is selected', () => {
@ -319,29 +257,29 @@ describe('OAuthClientSettingsModal', () => {
it('should default to Default client type when system_configured is true', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')
expect(defaultCard).toHaveAttribute('data-selected', 'true')
const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div')
expect(defaultCard).toHaveClass('border-[1.5px]')
})
it('should switch to Custom client type when Custom card is clicked', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
fireEvent.click(customCard)
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
fireEvent.click(customCard!)
expect(customCard).toHaveAttribute('data-selected', 'true')
expect(customCard).toHaveClass('border-[1.5px]')
})
it('should switch back to Default client type when Default card is clicked', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
fireEvent.click(customCard)
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
fireEvent.click(customCard!)
const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')
fireEvent.click(defaultCard)
const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div')
fireEvent.click(defaultCard!)
expect(defaultCard).toHaveAttribute('data-selected', 'true')
expect(defaultCard).toHaveClass('border-[1.5px]')
})
})
@ -852,8 +790,8 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
// Switch to custom
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
fireEvent.click(customCard)
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')
fireEvent.click(customCard!)
fireEvent.click(screen.getByTestId('modal-cancel'))
@ -1054,7 +992,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
// Switch to custom type
const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!
fireEvent.click(customCard)
fireEvent.click(screen.getByTestId('modal-cancel'))
@ -1077,7 +1015,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
// Switch to custom type
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
fireEvent.click(screen.getByTestId('modal-cancel'))
@ -1104,7 +1042,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
// Switch to custom type
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
fireEvent.click(screen.getByTestId('modal-cancel'))
@ -1131,7 +1069,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
// Switch to custom type
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
fireEvent.click(screen.getByTestId('modal-cancel'))
@ -1158,7 +1096,7 @@ describe('OAuthClientSettingsModal', () => {
render(<OAuthClientSettingsModal {...defaultProps} />)
// Switch to custom type
fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!)
fireEvent.click(screen.getByTestId('modal-cancel'))

View File

@ -1,27 +1,17 @@
'use client'
import type { FormRefObject } from '@/app/components/base/form/types'
import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers'
import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import {
RiClipboardLine,
RiInformation2Fill,
} from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { BaseForm } from '@/app/components/base/form/components/base'
import Modal from '@/app/components/base/modal/modal'
import Toast from '@/app/components/base/toast'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { openOAuthPopup } from '@/hooks/use-oauth'
import {
useConfigureTriggerOAuth,
useDeleteTriggerOAuth,
useInitiateTriggerOAuth,
useVerifyAndUpdateTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
import { usePluginStore } from '../../store'
import { ClientTypeEnum, useOAuthClientState } from './hooks/use-oauth-client-state'
type Props = {
oauthConfig?: TriggerOAuthConfig
@ -29,169 +19,38 @@ type Props = {
showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
}
enum AuthorizationStatusEnum {
Pending = 'pending',
Success = 'success',
Failed = 'failed',
}
enum ClientTypeEnum {
Default = 'default',
Custom = 'custom',
}
const CLIENT_TYPE_OPTIONS = [ClientTypeEnum.Default, ClientTypeEnum.Custom] as const
export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreateModal }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const { system_configured, params, oauth_client_schema } = oauthConfig || {}
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>()
const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>()
const [clientType, setClientType] = useState<ClientTypeEnum>(system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom)
const clientFormRef = React.useRef<FormRefObject>(null)
const oauthClientSchema = useMemo(() => {
if (oauth_client_schema && oauth_client_schema.length > 0 && params) {
const oauthConfigPramaKeys = Object.keys(params || {})
for (const schema of oauth_client_schema) {
if (oauthConfigPramaKeys.includes(schema.name))
schema.default = params?.[schema.name]
}
return oauth_client_schema
}
return []
}, [oauth_client_schema, params])
const providerName = detail?.provider || ''
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
const { mutate: verifyBuilder } = useVerifyAndUpdateTriggerSubscriptionBuilder()
const { mutate: configureOAuth } = useConfigureTriggerOAuth()
const { mutate: deleteOAuth } = useDeleteTriggerOAuth()
const confirmButtonText = useMemo(() => {
if (authorizationStatus === AuthorizationStatusEnum.Pending)
return t('modal.common.authorizing', { ns: 'pluginTrigger' })
if (authorizationStatus === AuthorizationStatusEnum.Success)
return t('modal.oauth.authorization.waitingJump', { ns: 'pluginTrigger' })
return t('auth.saveAndAuth', { ns: 'plugin' })
}, [authorizationStatus, t])
const {
clientType,
setClientType,
clientFormRef,
oauthClientSchema,
confirmButtonText,
handleRemove,
handleSave,
} = useOAuthClientState({
oauthConfig,
providerName,
onClose,
showOAuthCreateModal,
})
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 isCustomClient = clientType === ClientTypeEnum.Custom
const showRemoveButton = oauthConfig?.custom_enabled && oauthConfig?.params && isCustomClient
const showRedirectInfo = isCustomClient && oauthConfig?.redirect_uri
const showClientForm = isCustomClient && oauthClientSchema.length > 0
const handleAuthorization = () => {
setAuthorizationStatus(AuthorizationStatusEnum.Pending)
initiateOAuth(providerName, {
onSuccess: (response) => {
setSubscriptionBuilder(response.subscription_builder)
openOAuthPopup(response.authorization_url, (callbackData) => {
if (callbackData) {
Toast.notify({
type: 'success',
message: t('modal.oauth.authorization.authSuccess', { ns: 'pluginTrigger' }),
})
onClose()
showOAuthCreateModal(response.subscription_builder)
}
})
},
onError: () => {
setAuthorizationStatus(AuthorizationStatusEnum.Failed)
Toast.notify({
type: 'error',
message: t('modal.oauth.authorization.authFailed', { ns: 'pluginTrigger' }),
})
},
})
}
useEffect(() => {
if (providerName && subscriptionBuilder && authorizationStatus === AuthorizationStatusEnum.Pending) {
const pollInterval = setInterval(() => {
verifyBuilder(
{
provider: providerName,
subscriptionBuilderId: subscriptionBuilder.id,
},
{
onSuccess: (response) => {
if (response.verified) {
setAuthorizationStatus(AuthorizationStatusEnum.Success)
clearInterval(pollInterval)
}
},
onError: () => {
// Continue polling - auth might still be in progress
},
},
)
}, 3000)
return () => clearInterval(pollInterval)
}
}, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName, t])
const handleRemove = () => {
deleteOAuth(providerName, {
onSuccess: () => {
onClose()
Toast.notify({
type: 'success',
message: t('modal.oauth.remove.success', { ns: 'pluginTrigger' }),
})
},
onError: (error: unknown) => {
Toast.notify({
type: 'error',
message: getErrorMessage(error, t('modal.oauth.remove.failed', { ns: 'pluginTrigger' })),
})
},
})
}
const handleSave = (needAuth: boolean) => {
const isCustom = clientType === ClientTypeEnum.Custom
const params: ConfigureTriggerOAuthPayload = {
provider: providerName,
enabled: isCustom,
}
if (isCustom) {
const clientFormValues = clientFormRef.current?.getFormValues({}) as { values: TriggerOAuthClientParams, isCheckValidated: boolean }
if (!clientFormValues.isCheckValidated)
return
const clientParams = clientFormValues.values
if (clientParams.client_id === oauthConfig?.params.client_id)
clientParams.client_id = '[__HIDDEN__]'
if (clientParams.client_secret === oauthConfig?.params.client_secret)
clientParams.client_secret = '[__HIDDEN__]'
params.client_params = clientParams
}
configureOAuth(params, {
onSuccess: () => {
if (needAuth) {
handleAuthorization()
}
else {
onClose()
Toast.notify({
type: 'success',
message: t('modal.oauth.save.success', { ns: 'pluginTrigger' }),
})
}
},
const handleCopyRedirectUri = () => {
navigator.clipboard.writeText(oauthConfig?.redirect_uri || '')
Toast.notify({
type: 'success',
message: t('actionMsg.copySuccessfully', { ns: 'common' }),
})
}
@ -208,25 +67,25 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
onClose={onClose}
onCancel={() => handleSave(false)}
onConfirm={() => handleSave(true)}
footerSlot={
oauthConfig?.custom_enabled && oauthConfig?.params && clientType === ClientTypeEnum.Custom && (
<div className="grow">
<Button
variant="secondary"
className="text-components-button-destructive-secondary-text"
// disabled={disabled || doingAction || !editValues}
onClick={handleRemove}
>
{t('operation.remove', { ns: 'common' })}
</Button>
</div>
)
}
footerSlot={showRemoveButton && (
<div className="grow">
<Button
variant="secondary"
className="text-components-button-destructive-secondary-text"
onClick={handleRemove}
>
{t('operation.remove', { ns: 'common' })}
</Button>
</div>
)}
>
<div className="system-sm-medium mb-2 text-text-secondary">{t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })}</div>
<div className="system-sm-medium mb-2 text-text-secondary">
{t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })}
</div>
{oauthConfig?.system_configured && (
<div className="mb-4 flex w-full items-start justify-between gap-2">
{[ClientTypeEnum.Default, ClientTypeEnum.Custom].map(option => (
{CLIENT_TYPE_OPTIONS.map(option => (
<OptionCard
key={option}
title={t(`subscription.addType.options.oauth.${option}`, { ns: 'pluginTrigger' })}
@ -237,7 +96,8 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
))}
</div>
)}
{clientType === ClientTypeEnum.Custom && oauthConfig?.redirect_uri && (
{showRedirectInfo && (
<div className="mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4">
<div className="rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3">
<RiInformation2Fill className="h-5 w-5 shrink-0 text-text-accent" />
@ -247,18 +107,12 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
{t('modal.oauthRedirectInfo', { ns: 'pluginTrigger' })}
</div>
<div className="system-sm-medium my-1.5 break-all leading-4">
{oauthConfig.redirect_uri}
{oauthConfig?.redirect_uri}
</div>
<Button
variant="secondary"
size="small"
onClick={() => {
navigator.clipboard.writeText(oauthConfig.redirect_uri)
Toast.notify({
type: 'success',
message: t('actionMsg.copySuccessfully', { ns: 'common' }),
})
}}
onClick={handleCopyRedirectUri}
>
<RiClipboardLine className="mr-1 h-[14px] w-[14px]" />
{t('operation.copy', { ns: 'common' })}
@ -266,7 +120,8 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate
</div>
</div>
)}
{clientType === ClientTypeEnum.Custom && oauthClientSchema.length > 0 && (
{showClientForm && (
<BaseForm
formSchemas={oauthClientSchema}
ref={clientFormRef}

View File

@ -0,0 +1,6 @@
export enum CreateButtonType {
FULL_BUTTON = 'full-button',
ICON_BUTTON = 'icon-button',
}
export const DEFAULT_METHOD = 'default'

View File

@ -196,19 +196,19 @@ describe('useDocLink', () => {
const { result } = renderHook(() => useDocLink())
const url = result.current('/api-reference/annotations/create-annotation')
expect(url).toBe(`${defaultDocBaseUrl}/en/api-reference/annotations/create-annotation`)
expect(url).toBe(`${defaultDocBaseUrl}/api-reference/annotations/create-annotation`)
})
it('should keep original path when no translation exists for non-English locale', () => {
vi.mocked(useTranslation).mockReturnValue({
i18n: { language: 'ja-JP' },
i18n: { language: 'zh-Hans' },
} as ReturnType<typeof useTranslation>)
vi.mocked(getDocLanguage).mockReturnValue('ja')
vi.mocked(getDocLanguage).mockReturnValue('zh')
const { result } = renderHook(() => useDocLink())
// This path has no Japanese translation
const url = result.current('/api-reference/annotations/create-annotation')
expect(url).toBe(`${defaultDocBaseUrl}/ja/api-reference/annotations/create-annotation`)
expect(url).toBe(`${defaultDocBaseUrl}/api-reference/标注管理/创建标注`)
})
it('should remove language prefix when translation is applied', () => {

View File

@ -35,12 +35,13 @@ export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathM
let targetPath = (pathMap) ? pathMap[locale] || pathUrl : pathUrl
let languagePrefix = `/${docLanguage}`
// Translate API reference paths for non-English locales
if (targetPath.startsWith('/api-reference/') && docLanguage !== 'en') {
const translatedPath = apiReferencePathTranslations[targetPath]?.[docLanguage as 'zh' | 'ja']
if (translatedPath) {
targetPath = translatedPath
languagePrefix = ''
if (targetPath.startsWith('/api-reference/')) {
languagePrefix = ''
if (docLanguage !== 'en') {
const translatedPath = apiReferencePathTranslations[targetPath]?.[docLanguage]
if (translatedPath) {
targetPath = translatedPath
}
}
}

View File

@ -2445,11 +2445,6 @@
"count": 8
}
},
"app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx": {
"ts/no-explicit-any": {
"count": 8
}
},
"app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2503,14 +2498,6 @@
"count": 2
}
},
"app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx": {
"ts/no-explicit-any": {
"count": 1