Merge remote-tracking branch 'origin/main' into feat/rag-2
# Conflicts: # .github/workflows/build-push.yml # web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsxfeat/rag-2
commit
3388e83920
@ -0,0 +1,84 @@
|
|||||||
|
import json
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from json import JSONDecodeError
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from extensions.ext_redis import redis_client
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderCredentialsCache(ABC):
|
||||||
|
"""Base class for provider credentials cache"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.cache_key = self._generate_cache_key(**kwargs)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _generate_cache_key(self, **kwargs) -> str:
|
||||||
|
"""Generate cache key based on subclass implementation"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self) -> Optional[dict]:
|
||||||
|
"""Get cached provider credentials"""
|
||||||
|
cached_credentials = redis_client.get(self.cache_key)
|
||||||
|
if cached_credentials:
|
||||||
|
try:
|
||||||
|
cached_credentials = cached_credentials.decode("utf-8")
|
||||||
|
return dict(json.loads(cached_credentials))
|
||||||
|
except JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, config: dict[str, Any]) -> None:
|
||||||
|
"""Cache provider credentials"""
|
||||||
|
redis_client.setex(self.cache_key, 86400, json.dumps(config))
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete cached provider credentials"""
|
||||||
|
redis_client.delete(self.cache_key)
|
||||||
|
|
||||||
|
|
||||||
|
class SingletonProviderCredentialsCache(ProviderCredentialsCache):
|
||||||
|
"""Cache for tool single provider credentials"""
|
||||||
|
|
||||||
|
def __init__(self, tenant_id: str, provider_type: str, provider_identity: str):
|
||||||
|
super().__init__(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
provider_type=provider_type,
|
||||||
|
provider_identity=provider_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_cache_key(self, **kwargs) -> str:
|
||||||
|
tenant_id = kwargs["tenant_id"]
|
||||||
|
provider_type = kwargs["provider_type"]
|
||||||
|
identity_name = kwargs["provider_identity"]
|
||||||
|
identity_id = f"{provider_type}.{identity_name}"
|
||||||
|
return f"{provider_type}_credentials:tenant_id:{tenant_id}:id:{identity_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolProviderCredentialsCache(ProviderCredentialsCache):
|
||||||
|
"""Cache for tool provider credentials"""
|
||||||
|
|
||||||
|
def __init__(self, tenant_id: str, provider: str, credential_id: str):
|
||||||
|
super().__init__(tenant_id=tenant_id, provider=provider, credential_id=credential_id)
|
||||||
|
|
||||||
|
def _generate_cache_key(self, **kwargs) -> str:
|
||||||
|
tenant_id = kwargs["tenant_id"]
|
||||||
|
provider = kwargs["provider"]
|
||||||
|
credential_id = kwargs["credential_id"]
|
||||||
|
return f"tool_credentials:tenant_id:{tenant_id}:provider:{provider}:credential_id:{credential_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class NoOpProviderCredentialCache:
|
||||||
|
"""No-op provider credential cache"""
|
||||||
|
|
||||||
|
def get(self) -> Optional[dict]:
|
||||||
|
"""Get cached provider credentials"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, config: dict[str, Any]) -> None:
|
||||||
|
"""Cache provider credentials"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete cached provider credentials"""
|
||||||
|
pass
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import json
|
|
||||||
from enum import Enum
|
|
||||||
from json import JSONDecodeError
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from extensions.ext_redis import redis_client
|
|
||||||
|
|
||||||
|
|
||||||
class ToolProviderCredentialsCacheType(Enum):
|
|
||||||
PROVIDER = "tool_provider"
|
|
||||||
ENDPOINT = "endpoint"
|
|
||||||
|
|
||||||
|
|
||||||
class ToolProviderCredentialsCache:
|
|
||||||
def __init__(self, tenant_id: str, identity_id: str, cache_type: ToolProviderCredentialsCacheType):
|
|
||||||
self.cache_key = f"{cache_type.value}_credentials:tenant_id:{tenant_id}:id:{identity_id}"
|
|
||||||
|
|
||||||
def get(self) -> Optional[dict]:
|
|
||||||
"""
|
|
||||||
Get cached model provider credentials.
|
|
||||||
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
cached_provider_credentials = redis_client.get(self.cache_key)
|
|
||||||
if cached_provider_credentials:
|
|
||||||
try:
|
|
||||||
cached_provider_credentials = cached_provider_credentials.decode("utf-8")
|
|
||||||
cached_provider_credentials = json.loads(cached_provider_credentials)
|
|
||||||
except JSONDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return dict(cached_provider_credentials)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def set(self, credentials: dict) -> None:
|
|
||||||
"""
|
|
||||||
Cache model provider credentials.
|
|
||||||
|
|
||||||
:param credentials: provider credentials
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
redis_client.setex(self.cache_key, 86400, json.dumps(credentials))
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
"""
|
|
||||||
Delete cached model provider credentials.
|
|
||||||
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
redis_client.delete(self.cache_key)
|
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
|
from core.entities.provider_entities import BasicProviderConfig
|
||||||
|
from core.helper import encrypter
|
||||||
|
from core.helper.provider_cache import SingletonProviderCredentialsCache
|
||||||
|
from core.tools.__base.tool_provider import ToolProviderController
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderConfigCache(Protocol):
|
||||||
|
"""
|
||||||
|
Interface for provider configuration cache operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self) -> Optional[dict]:
|
||||||
|
"""Get cached provider configuration"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set(self, config: dict[str, Any]) -> None:
|
||||||
|
"""Cache provider configuration"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete cached provider configuration"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderConfigEncrypter:
|
||||||
|
tenant_id: str
|
||||||
|
config: list[BasicProviderConfig]
|
||||||
|
provider_config_cache: ProviderConfigCache
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
config: list[BasicProviderConfig],
|
||||||
|
provider_config_cache: ProviderConfigCache,
|
||||||
|
):
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.config = config
|
||||||
|
self.provider_config_cache = provider_config_cache
|
||||||
|
|
||||||
|
def _deep_copy(self, data: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
deep copy data
|
||||||
|
"""
|
||||||
|
return deepcopy(data)
|
||||||
|
|
||||||
|
def encrypt(self, data: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
encrypt tool credentials with tenant id
|
||||||
|
|
||||||
|
return a deep copy of credentials with encrypted values
|
||||||
|
"""
|
||||||
|
data = self._deep_copy(data)
|
||||||
|
|
||||||
|
# get fields need to be decrypted
|
||||||
|
fields = dict[str, BasicProviderConfig]()
|
||||||
|
for credential in self.config:
|
||||||
|
fields[credential.name] = credential
|
||||||
|
|
||||||
|
for field_name, field in fields.items():
|
||||||
|
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
|
||||||
|
if field_name in data:
|
||||||
|
encrypted = encrypter.encrypt_token(self.tenant_id, data[field_name] or "")
|
||||||
|
data[field_name] = encrypted
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def mask_tool_credentials(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
mask tool credentials
|
||||||
|
|
||||||
|
return a deep copy of credentials with masked values
|
||||||
|
"""
|
||||||
|
data = self._deep_copy(data)
|
||||||
|
|
||||||
|
# get fields need to be decrypted
|
||||||
|
fields = dict[str, BasicProviderConfig]()
|
||||||
|
for credential in self.config:
|
||||||
|
fields[credential.name] = credential
|
||||||
|
|
||||||
|
for field_name, field in fields.items():
|
||||||
|
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
|
||||||
|
if field_name in data:
|
||||||
|
if len(data[field_name]) > 6:
|
||||||
|
data[field_name] = (
|
||||||
|
data[field_name][:2] + "*" * (len(data[field_name]) - 4) + data[field_name][-2:]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
data[field_name] = "*" * len(data[field_name])
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def decrypt(self, data: dict[str, str]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
decrypt tool credentials with tenant id
|
||||||
|
|
||||||
|
return a deep copy of credentials with decrypted values
|
||||||
|
"""
|
||||||
|
cached_credentials = self.provider_config_cache.get()
|
||||||
|
if cached_credentials:
|
||||||
|
return cached_credentials
|
||||||
|
|
||||||
|
data = self._deep_copy(data)
|
||||||
|
# get fields need to be decrypted
|
||||||
|
fields = dict[str, BasicProviderConfig]()
|
||||||
|
for credential in self.config:
|
||||||
|
fields[credential.name] = credential
|
||||||
|
|
||||||
|
for field_name, field in fields.items():
|
||||||
|
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
|
||||||
|
if field_name in data:
|
||||||
|
try:
|
||||||
|
# if the value is None or empty string, skip decrypt
|
||||||
|
if not data[field_name]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data[field_name] = encrypter.decrypt_token(self.tenant_id, data[field_name])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.provider_config_cache.set(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def create_provider_encrypter(tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache):
|
||||||
|
return ProviderConfigEncrypter(tenant_id=tenant_id, config=config, provider_config_cache=cache), cache
|
||||||
|
|
||||||
|
|
||||||
|
def create_tool_provider_encrypter(tenant_id: str, controller: ToolProviderController):
|
||||||
|
cache = SingletonProviderCredentialsCache(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
provider_type=controller.provider_type.value,
|
||||||
|
provider_identity=controller.entity.identity.name,
|
||||||
|
)
|
||||||
|
encrypt = ProviderConfigEncrypter(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
config=[x.to_basic_provider_config() for x in controller.get_credentials_schema()],
|
||||||
|
provider_config_cache=cache,
|
||||||
|
)
|
||||||
|
return encrypt, cache
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from Crypto.Random import get_random_bytes
|
||||||
|
from Crypto.Util.Padding import pad, unpad
|
||||||
|
from pydantic import TypeAdapter
|
||||||
|
|
||||||
|
from configs import dify_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthEncryptionError(Exception):
|
||||||
|
"""OAuth encryption/decryption specific error"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SystemOAuthEncrypter:
|
||||||
|
"""
|
||||||
|
A simple OAuth parameters encrypter using AES-CBC encryption.
|
||||||
|
|
||||||
|
This class provides methods to encrypt and decrypt OAuth parameters
|
||||||
|
using AES-CBC mode with a key derived from the application's SECRET_KEY.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, secret_key: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize the OAuth encrypter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
secret_key: Optional secret key. If not provided, uses dify_config.SECRET_KEY
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If SECRET_KEY is not configured or empty
|
||||||
|
"""
|
||||||
|
secret_key = secret_key or dify_config.SECRET_KEY or ""
|
||||||
|
|
||||||
|
# Generate a fixed 256-bit key using SHA-256
|
||||||
|
self.key = hashlib.sha256(secret_key.encode()).digest()
|
||||||
|
|
||||||
|
def encrypt_oauth_params(self, oauth_params: Mapping[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Encrypt OAuth parameters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
oauth_params: OAuth parameters dictionary, e.g., {"client_id": "xxx", "client_secret": "xxx"}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded encrypted string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OAuthEncryptionError: If encryption fails
|
||||||
|
ValueError: If oauth_params is invalid
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Generate random IV (16 bytes)
|
||||||
|
iv = get_random_bytes(16)
|
||||||
|
|
||||||
|
# Create AES cipher (CBC mode)
|
||||||
|
cipher = AES.new(self.key, AES.MODE_CBC, iv)
|
||||||
|
|
||||||
|
# Encrypt data
|
||||||
|
padded_data = pad(TypeAdapter(dict).dump_json(dict(oauth_params)), AES.block_size)
|
||||||
|
encrypted_data = cipher.encrypt(padded_data)
|
||||||
|
|
||||||
|
# Combine IV and encrypted data
|
||||||
|
combined = iv + encrypted_data
|
||||||
|
|
||||||
|
# Return base64 encoded string
|
||||||
|
return base64.b64encode(combined).decode()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise OAuthEncryptionError(f"Encryption failed: {str(e)}") from e
|
||||||
|
|
||||||
|
def decrypt_oauth_params(self, encrypted_data: str) -> Mapping[str, Any]:
|
||||||
|
"""
|
||||||
|
Decrypt OAuth parameters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted_data: Base64-encoded encrypted string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted OAuth parameters dictionary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OAuthEncryptionError: If decryption fails
|
||||||
|
ValueError: If encrypted_data is invalid
|
||||||
|
"""
|
||||||
|
if not isinstance(encrypted_data, str):
|
||||||
|
raise ValueError("encrypted_data must be a string")
|
||||||
|
|
||||||
|
if not encrypted_data:
|
||||||
|
raise ValueError("encrypted_data cannot be empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Base64 decode
|
||||||
|
combined = base64.b64decode(encrypted_data)
|
||||||
|
|
||||||
|
# Check minimum length (IV + at least one AES block)
|
||||||
|
if len(combined) < 32: # 16 bytes IV + 16 bytes minimum encrypted data
|
||||||
|
raise ValueError("Invalid encrypted data format")
|
||||||
|
|
||||||
|
# Separate IV and encrypted data
|
||||||
|
iv = combined[:16]
|
||||||
|
encrypted_data_bytes = combined[16:]
|
||||||
|
|
||||||
|
# Create AES cipher
|
||||||
|
cipher = AES.new(self.key, AES.MODE_CBC, iv)
|
||||||
|
|
||||||
|
# Decrypt data
|
||||||
|
decrypted_data = cipher.decrypt(encrypted_data_bytes)
|
||||||
|
unpadded_data = unpad(decrypted_data, AES.block_size)
|
||||||
|
|
||||||
|
# Parse JSON
|
||||||
|
oauth_params: Mapping[str, Any] = TypeAdapter(Mapping[str, Any]).validate_json(unpadded_data)
|
||||||
|
|
||||||
|
if not isinstance(oauth_params, dict):
|
||||||
|
raise ValueError("Decrypted data is not a valid dictionary")
|
||||||
|
|
||||||
|
return oauth_params
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise OAuthEncryptionError(f"Decryption failed: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
# Factory function for creating encrypter instances
|
||||||
|
def create_system_oauth_encrypter(secret_key: Optional[str] = None) -> SystemOAuthEncrypter:
|
||||||
|
"""
|
||||||
|
Create an OAuth encrypter instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
secret_key: Optional secret key. If not provided, uses dify_config.SECRET_KEY
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SystemOAuthEncrypter instance
|
||||||
|
"""
|
||||||
|
return SystemOAuthEncrypter(secret_key=secret_key)
|
||||||
|
|
||||||
|
|
||||||
|
# Global encrypter instance (for backward compatibility)
|
||||||
|
_oauth_encrypter: Optional[SystemOAuthEncrypter] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_oauth_encrypter() -> SystemOAuthEncrypter:
|
||||||
|
"""
|
||||||
|
Get the global OAuth encrypter instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SystemOAuthEncrypter instance
|
||||||
|
"""
|
||||||
|
global _oauth_encrypter
|
||||||
|
if _oauth_encrypter is None:
|
||||||
|
_oauth_encrypter = SystemOAuthEncrypter()
|
||||||
|
return _oauth_encrypter
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience functions for backward compatibility
|
||||||
|
def encrypt_system_oauth_params(oauth_params: Mapping[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Encrypt OAuth parameters using the global encrypter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
oauth_params: OAuth parameters dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded encrypted string
|
||||||
|
"""
|
||||||
|
return get_system_oauth_encrypter().encrypt_oauth_params(oauth_params)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_system_oauth_params(encrypted_data: str) -> Mapping[str, Any]:
|
||||||
|
"""
|
||||||
|
Decrypt OAuth parameters using the global encrypter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted_data: Base64-encoded encrypted string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted OAuth parameters dictionary
|
||||||
|
"""
|
||||||
|
return get_system_oauth_encrypter().decrypt_oauth_params(encrypted_data)
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 16081485540c
|
||||||
|
Revises: d28f2004b072
|
||||||
|
Create Date: 2025-05-15 16:35:39.113777
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import models as models
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '16081485540c'
|
||||||
|
down_revision = '2adcbe1f5dfb'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('tenant_plugin_auto_upgrade_strategies',
|
||||||
|
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
|
||||||
|
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column('strategy_setting', sa.String(length=16), server_default='fix_only', nullable=False),
|
||||||
|
sa.Column('upgrade_time_of_day', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('upgrade_mode', sa.String(length=16), server_default='exclude', nullable=False),
|
||||||
|
sa.Column('exclude_plugins', sa.ARRAY(sa.String(length=255)), nullable=False),
|
||||||
|
sa.Column('include_plugins', sa.ARRAY(sa.String(length=255)), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='tenant_plugin_auto_upgrade_strategy_pkey'),
|
||||||
|
sa.UniqueConstraint('tenant_id', name='unique_tenant_plugin_auto_upgrade_strategy')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('tenant_plugin_auto_upgrade_strategies')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
"""tool oauth
|
||||||
|
|
||||||
|
Revision ID: 71f5020c6470
|
||||||
|
Revises: 4474872b0ee6
|
||||||
|
Create Date: 2025-06-24 17:05:43.118647
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import models as models
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '71f5020c6470'
|
||||||
|
down_revision = '1c9ba48be8e4'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('tool_oauth_system_clients',
|
||||||
|
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
|
||||||
|
sa.Column('plugin_id', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('encrypted_oauth_params', sa.Text(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='tool_oauth_system_client_pkey'),
|
||||||
|
sa.UniqueConstraint('plugin_id', 'provider', name='tool_oauth_system_client_plugin_id_provider_idx')
|
||||||
|
)
|
||||||
|
op.create_table('tool_oauth_tenant_clients',
|
||||||
|
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
|
||||||
|
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column('plugin_id', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False),
|
||||||
|
sa.Column('encrypted_oauth_params', sa.Text(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='tool_oauth_tenant_client_pkey'),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'plugin_id', 'provider', name='unique_tool_oauth_tenant_client')
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('tool_builtin_providers', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('name', sa.String(length=256), server_default=sa.text("'API KEY 1'::character varying"), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('is_default', sa.Boolean(), server_default=sa.text('false'), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('credential_type', sa.String(length=32), server_default=sa.text("'api-key'::character varying"), nullable=False))
|
||||||
|
batch_op.drop_constraint(batch_op.f('unique_builtin_tool_provider'), type_='unique')
|
||||||
|
batch_op.create_unique_constraint(batch_op.f('unique_builtin_tool_provider'), ['tenant_id', 'provider', 'name'])
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('tool_builtin_providers', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint(batch_op.f('unique_builtin_tool_provider'), type_='unique')
|
||||||
|
batch_op.create_unique_constraint(batch_op.f('unique_builtin_tool_provider'), ['tenant_id', 'provider'])
|
||||||
|
batch_op.drop_column('credential_type')
|
||||||
|
batch_op.drop_column('is_default')
|
||||||
|
batch_op.drop_column('name')
|
||||||
|
|
||||||
|
op.drop_table('tool_oauth_tenant_clients')
|
||||||
|
op.drop_table('tool_oauth_system_clients')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -0,0 +1,619 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from Crypto.Random import get_random_bytes
|
||||||
|
from Crypto.Util.Padding import pad
|
||||||
|
|
||||||
|
from core.tools.utils.system_oauth_encryption import (
|
||||||
|
OAuthEncryptionError,
|
||||||
|
SystemOAuthEncrypter,
|
||||||
|
create_system_oauth_encrypter,
|
||||||
|
decrypt_system_oauth_params,
|
||||||
|
encrypt_system_oauth_params,
|
||||||
|
get_system_oauth_encrypter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemOAuthEncrypter:
|
||||||
|
"""Test cases for SystemOAuthEncrypter class"""
|
||||||
|
|
||||||
|
def test_init_with_secret_key(self):
|
||||||
|
"""Test initialization with provided secret key"""
|
||||||
|
secret_key = "test_secret_key"
|
||||||
|
encrypter = SystemOAuthEncrypter(secret_key=secret_key)
|
||||||
|
expected_key = hashlib.sha256(secret_key.encode()).digest()
|
||||||
|
assert encrypter.key == expected_key
|
||||||
|
|
||||||
|
def test_init_with_none_secret_key(self):
|
||||||
|
"""Test initialization with None secret key falls back to config"""
|
||||||
|
with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config:
|
||||||
|
mock_config.SECRET_KEY = "config_secret"
|
||||||
|
encrypter = SystemOAuthEncrypter(secret_key=None)
|
||||||
|
expected_key = hashlib.sha256(b"config_secret").digest()
|
||||||
|
assert encrypter.key == expected_key
|
||||||
|
|
||||||
|
def test_init_with_empty_secret_key(self):
|
||||||
|
"""Test initialization with empty secret key"""
|
||||||
|
encrypter = SystemOAuthEncrypter(secret_key="")
|
||||||
|
expected_key = hashlib.sha256(b"").digest()
|
||||||
|
assert encrypter.key == expected_key
|
||||||
|
|
||||||
|
def test_init_without_secret_key_uses_config(self):
|
||||||
|
"""Test initialization without secret key uses config"""
|
||||||
|
with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config:
|
||||||
|
mock_config.SECRET_KEY = "default_secret"
|
||||||
|
encrypter = SystemOAuthEncrypter()
|
||||||
|
expected_key = hashlib.sha256(b"default_secret").digest()
|
||||||
|
assert encrypter.key == expected_key
|
||||||
|
|
||||||
|
def test_encrypt_oauth_params_basic(self):
|
||||||
|
"""Test basic OAuth parameters encryption"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
oauth_params = {"client_id": "test_id", "client_secret": "test_secret"}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
|
||||||
|
assert isinstance(encrypted, str)
|
||||||
|
assert len(encrypted) > 0
|
||||||
|
# Should be valid base64
|
||||||
|
try:
|
||||||
|
base64.b64decode(encrypted)
|
||||||
|
except Exception:
|
||||||
|
pytest.fail("Encrypted result is not valid base64")
|
||||||
|
|
||||||
|
def test_encrypt_oauth_params_empty_dict(self):
|
||||||
|
"""Test encryption with empty dictionary"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
oauth_params = {}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
assert isinstance(encrypted, str)
|
||||||
|
assert len(encrypted) > 0
|
||||||
|
|
||||||
|
def test_encrypt_oauth_params_complex_data(self):
|
||||||
|
"""Test encryption with complex data structures"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
oauth_params = {
|
||||||
|
"client_id": "test_id",
|
||||||
|
"client_secret": "test_secret",
|
||||||
|
"scopes": ["read", "write", "admin"],
|
||||||
|
"metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True},
|
||||||
|
"numeric_value": 42,
|
||||||
|
"boolean_value": False,
|
||||||
|
"null_value": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
assert isinstance(encrypted, str)
|
||||||
|
assert len(encrypted) > 0
|
||||||
|
|
||||||
|
def test_encrypt_oauth_params_unicode_data(self):
|
||||||
|
"""Test encryption with unicode data"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
oauth_params = {"client_id": "test_id", "client_secret": "test_secret", "description": "This is a test case 🚀"}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
assert isinstance(encrypted, str)
|
||||||
|
assert len(encrypted) > 0
|
||||||
|
|
||||||
|
def test_encrypt_oauth_params_large_data(self):
|
||||||
|
"""Test encryption with large data"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
oauth_params = {
|
||||||
|
"client_id": "test_id",
|
||||||
|
"large_data": "x" * 10000, # 10KB of data
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
assert isinstance(encrypted, str)
|
||||||
|
assert len(encrypted) > 0
|
||||||
|
|
||||||
|
def test_encrypt_oauth_params_invalid_input(self):
|
||||||
|
"""Test encryption with invalid input types"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
|
||||||
|
with pytest.raises(Exception): # noqa: B017
|
||||||
|
encrypter.encrypt_oauth_params(None) # type: ignore
|
||||||
|
|
||||||
|
with pytest.raises(Exception): # noqa: B017
|
||||||
|
encrypter.encrypt_oauth_params("not_a_dict") # type: ignore
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_basic(self):
|
||||||
|
"""Test basic OAuth parameters decryption"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
original_params = {"client_id": "test_id", "client_secret": "test_secret"}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(original_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
|
||||||
|
assert decrypted == original_params
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_empty_dict(self):
|
||||||
|
"""Test decryption of empty dictionary"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
original_params = {}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(original_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
|
||||||
|
assert decrypted == original_params
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_complex_data(self):
|
||||||
|
"""Test decryption with complex data structures"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
original_params = {
|
||||||
|
"client_id": "test_id",
|
||||||
|
"client_secret": "test_secret",
|
||||||
|
"scopes": ["read", "write", "admin"],
|
||||||
|
"metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True},
|
||||||
|
"numeric_value": 42,
|
||||||
|
"boolean_value": False,
|
||||||
|
"null_value": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(original_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
|
||||||
|
assert decrypted == original_params
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_unicode_data(self):
|
||||||
|
"""Test decryption with unicode data"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
original_params = {
|
||||||
|
"client_id": "test_id",
|
||||||
|
"client_secret": "test_secret",
|
||||||
|
"description": "This is a test case 🚀",
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(original_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
|
||||||
|
assert decrypted == original_params
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_large_data(self):
|
||||||
|
"""Test decryption with large data"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
original_params = {
|
||||||
|
"client_id": "test_id",
|
||||||
|
"large_data": "x" * 10000, # 10KB of data
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(original_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
|
||||||
|
assert decrypted == original_params
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_invalid_base64(self):
|
||||||
|
"""Test decryption with invalid base64 data"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
|
||||||
|
with pytest.raises(OAuthEncryptionError):
|
||||||
|
encrypter.decrypt_oauth_params("invalid_base64!")
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_empty_string(self):
|
||||||
|
"""Test decryption with empty string"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
encrypter.decrypt_oauth_params("")
|
||||||
|
|
||||||
|
assert "encrypted_data cannot be empty" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_non_string_input(self):
|
||||||
|
"""Test decryption with non-string input"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
encrypter.decrypt_oauth_params(123) # type: ignore
|
||||||
|
|
||||||
|
assert "encrypted_data must be a string" in str(exc_info.value)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
encrypter.decrypt_oauth_params(None) # type: ignore
|
||||||
|
|
||||||
|
assert "encrypted_data must be a string" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_too_short_data(self):
|
||||||
|
"""Test decryption with too short encrypted data"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
|
||||||
|
# Create data that's too short (less than 32 bytes)
|
||||||
|
short_data = base64.b64encode(b"short").decode()
|
||||||
|
|
||||||
|
with pytest.raises(OAuthEncryptionError) as exc_info:
|
||||||
|
encrypter.decrypt_oauth_params(short_data)
|
||||||
|
|
||||||
|
assert "Invalid encrypted data format" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_corrupted_data(self):
|
||||||
|
"""Test decryption with corrupted data"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
|
||||||
|
# Create corrupted data (valid base64 but invalid encrypted content)
|
||||||
|
corrupted_data = base64.b64encode(b"x" * 48).decode() # 48 bytes of garbage
|
||||||
|
|
||||||
|
with pytest.raises(OAuthEncryptionError):
|
||||||
|
encrypter.decrypt_oauth_params(corrupted_data)
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_wrong_key(self):
|
||||||
|
"""Test decryption with wrong key"""
|
||||||
|
encrypter1 = SystemOAuthEncrypter("secret1")
|
||||||
|
encrypter2 = SystemOAuthEncrypter("secret2")
|
||||||
|
|
||||||
|
original_params = {"client_id": "test_id", "client_secret": "test_secret"}
|
||||||
|
encrypted = encrypter1.encrypt_oauth_params(original_params)
|
||||||
|
|
||||||
|
with pytest.raises(OAuthEncryptionError):
|
||||||
|
encrypter2.decrypt_oauth_params(encrypted)
|
||||||
|
|
||||||
|
def test_encryption_decryption_consistency(self):
|
||||||
|
"""Test that encryption and decryption are consistent"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
|
||||||
|
test_cases = [
|
||||||
|
{},
|
||||||
|
{"simple": "value"},
|
||||||
|
{"client_id": "id", "client_secret": "secret"},
|
||||||
|
{"complex": {"nested": {"deep": "value"}}},
|
||||||
|
{"unicode": "test 🚀"},
|
||||||
|
{"numbers": 42, "boolean": True, "null": None},
|
||||||
|
{"array": [1, 2, 3, "four", {"five": 5}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
for original_params in test_cases:
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(original_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
assert decrypted == original_params, f"Failed for case: {original_params}"
|
||||||
|
|
||||||
|
def test_encryption_randomness(self):
|
||||||
|
"""Test that encryption produces different results for same input"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
oauth_params = {"client_id": "test_id", "client_secret": "test_secret"}
|
||||||
|
|
||||||
|
encrypted1 = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
encrypted2 = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
|
||||||
|
# Should be different due to random IV
|
||||||
|
assert encrypted1 != encrypted2
|
||||||
|
|
||||||
|
# But should decrypt to same result
|
||||||
|
decrypted1 = encrypter.decrypt_oauth_params(encrypted1)
|
||||||
|
decrypted2 = encrypter.decrypt_oauth_params(encrypted2)
|
||||||
|
assert decrypted1 == decrypted2 == oauth_params
|
||||||
|
|
||||||
|
def test_different_secret_keys_produce_different_results(self):
|
||||||
|
"""Test that different secret keys produce different encrypted results"""
|
||||||
|
encrypter1 = SystemOAuthEncrypter("secret1")
|
||||||
|
encrypter2 = SystemOAuthEncrypter("secret2")
|
||||||
|
|
||||||
|
oauth_params = {"client_id": "test_id", "client_secret": "test_secret"}
|
||||||
|
|
||||||
|
encrypted1 = encrypter1.encrypt_oauth_params(oauth_params)
|
||||||
|
encrypted2 = encrypter2.encrypt_oauth_params(oauth_params)
|
||||||
|
|
||||||
|
# Should produce different encrypted results
|
||||||
|
assert encrypted1 != encrypted2
|
||||||
|
|
||||||
|
# But each should decrypt correctly with its own key
|
||||||
|
decrypted1 = encrypter1.decrypt_oauth_params(encrypted1)
|
||||||
|
decrypted2 = encrypter2.decrypt_oauth_params(encrypted2)
|
||||||
|
assert decrypted1 == decrypted2 == oauth_params
|
||||||
|
|
||||||
|
@patch("core.tools.utils.system_oauth_encryption.get_random_bytes")
|
||||||
|
def test_encrypt_oauth_params_crypto_error(self, mock_get_random_bytes):
|
||||||
|
"""Test encryption when crypto operation fails"""
|
||||||
|
mock_get_random_bytes.side_effect = Exception("Crypto error")
|
||||||
|
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
oauth_params = {"client_id": "test_id"}
|
||||||
|
|
||||||
|
with pytest.raises(OAuthEncryptionError) as exc_info:
|
||||||
|
encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
|
||||||
|
assert "Encryption failed" in str(exc_info.value)
|
||||||
|
|
||||||
|
@patch("core.tools.utils.system_oauth_encryption.TypeAdapter")
|
||||||
|
def test_encrypt_oauth_params_serialization_error(self, mock_type_adapter):
|
||||||
|
"""Test encryption when JSON serialization fails"""
|
||||||
|
mock_type_adapter.return_value.dump_json.side_effect = Exception("Serialization error")
|
||||||
|
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
oauth_params = {"client_id": "test_id"}
|
||||||
|
|
||||||
|
with pytest.raises(OAuthEncryptionError) as exc_info:
|
||||||
|
encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
|
||||||
|
assert "Encryption failed" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_decrypt_oauth_params_invalid_json(self):
|
||||||
|
"""Test decryption with invalid JSON data"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
|
||||||
|
# Create valid encrypted data but with invalid JSON content
|
||||||
|
iv = get_random_bytes(16)
|
||||||
|
cipher = AES.new(encrypter.key, AES.MODE_CBC, iv)
|
||||||
|
invalid_json = b"invalid json content"
|
||||||
|
padded_data = pad(invalid_json, AES.block_size)
|
||||||
|
encrypted_data = cipher.encrypt(padded_data)
|
||||||
|
combined = iv + encrypted_data
|
||||||
|
encoded = base64.b64encode(combined).decode()
|
||||||
|
|
||||||
|
with pytest.raises(OAuthEncryptionError):
|
||||||
|
encrypter.decrypt_oauth_params(encoded)
|
||||||
|
|
||||||
|
def test_key_derivation_consistency(self):
|
||||||
|
"""Test that key derivation is consistent"""
|
||||||
|
secret_key = "test_secret"
|
||||||
|
encrypter1 = SystemOAuthEncrypter(secret_key)
|
||||||
|
encrypter2 = SystemOAuthEncrypter(secret_key)
|
||||||
|
|
||||||
|
assert encrypter1.key == encrypter2.key
|
||||||
|
|
||||||
|
# Keys should be 32 bytes (256 bits)
|
||||||
|
assert len(encrypter1.key) == 32
|
||||||
|
|
||||||
|
|
||||||
|
class TestFactoryFunctions:
|
||||||
|
"""Test cases for factory functions"""
|
||||||
|
|
||||||
|
def test_create_system_oauth_encrypter_with_secret(self):
|
||||||
|
"""Test factory function with secret key"""
|
||||||
|
secret_key = "test_secret"
|
||||||
|
encrypter = create_system_oauth_encrypter(secret_key)
|
||||||
|
|
||||||
|
assert isinstance(encrypter, SystemOAuthEncrypter)
|
||||||
|
expected_key = hashlib.sha256(secret_key.encode()).digest()
|
||||||
|
assert encrypter.key == expected_key
|
||||||
|
|
||||||
|
def test_create_system_oauth_encrypter_without_secret(self):
|
||||||
|
"""Test factory function without secret key"""
|
||||||
|
with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config:
|
||||||
|
mock_config.SECRET_KEY = "config_secret"
|
||||||
|
encrypter = create_system_oauth_encrypter()
|
||||||
|
|
||||||
|
assert isinstance(encrypter, SystemOAuthEncrypter)
|
||||||
|
expected_key = hashlib.sha256(b"config_secret").digest()
|
||||||
|
assert encrypter.key == expected_key
|
||||||
|
|
||||||
|
def test_create_system_oauth_encrypter_with_none_secret(self):
|
||||||
|
"""Test factory function with None secret key"""
|
||||||
|
with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config:
|
||||||
|
mock_config.SECRET_KEY = "config_secret"
|
||||||
|
encrypter = create_system_oauth_encrypter(None)
|
||||||
|
|
||||||
|
assert isinstance(encrypter, SystemOAuthEncrypter)
|
||||||
|
expected_key = hashlib.sha256(b"config_secret").digest()
|
||||||
|
assert encrypter.key == expected_key
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalEncrypterInstance:
|
||||||
|
"""Test cases for global encrypter instance"""
|
||||||
|
|
||||||
|
def test_get_system_oauth_encrypter_singleton(self):
|
||||||
|
"""Test that get_system_oauth_encrypter returns singleton instance"""
|
||||||
|
# Clear the global instance first
|
||||||
|
import core.tools.utils.system_oauth_encryption
|
||||||
|
|
||||||
|
core.tools.utils.system_oauth_encryption._oauth_encrypter = None
|
||||||
|
|
||||||
|
encrypter1 = get_system_oauth_encrypter()
|
||||||
|
encrypter2 = get_system_oauth_encrypter()
|
||||||
|
|
||||||
|
assert encrypter1 is encrypter2
|
||||||
|
assert isinstance(encrypter1, SystemOAuthEncrypter)
|
||||||
|
|
||||||
|
def test_get_system_oauth_encrypter_uses_config(self):
|
||||||
|
"""Test that global encrypter uses config"""
|
||||||
|
# Clear the global instance first
|
||||||
|
import core.tools.utils.system_oauth_encryption
|
||||||
|
|
||||||
|
core.tools.utils.system_oauth_encryption._oauth_encrypter = None
|
||||||
|
|
||||||
|
with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config:
|
||||||
|
mock_config.SECRET_KEY = "global_secret"
|
||||||
|
encrypter = get_system_oauth_encrypter()
|
||||||
|
|
||||||
|
expected_key = hashlib.sha256(b"global_secret").digest()
|
||||||
|
assert encrypter.key == expected_key
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvenienceFunctions:
|
||||||
|
"""Test cases for convenience functions"""
|
||||||
|
|
||||||
|
def test_encrypt_system_oauth_params(self):
|
||||||
|
"""Test encrypt_system_oauth_params convenience function"""
|
||||||
|
oauth_params = {"client_id": "test_id", "client_secret": "test_secret"}
|
||||||
|
|
||||||
|
encrypted = encrypt_system_oauth_params(oauth_params)
|
||||||
|
|
||||||
|
assert isinstance(encrypted, str)
|
||||||
|
assert len(encrypted) > 0
|
||||||
|
|
||||||
|
def test_decrypt_system_oauth_params(self):
|
||||||
|
"""Test decrypt_system_oauth_params convenience function"""
|
||||||
|
oauth_params = {"client_id": "test_id", "client_secret": "test_secret"}
|
||||||
|
|
||||||
|
encrypted = encrypt_system_oauth_params(oauth_params)
|
||||||
|
decrypted = decrypt_system_oauth_params(encrypted)
|
||||||
|
|
||||||
|
assert decrypted == oauth_params
|
||||||
|
|
||||||
|
def test_convenience_functions_consistency(self):
|
||||||
|
"""Test that convenience functions work consistently"""
|
||||||
|
test_cases = [
|
||||||
|
{},
|
||||||
|
{"simple": "value"},
|
||||||
|
{"client_id": "id", "client_secret": "secret"},
|
||||||
|
{"complex": {"nested": {"deep": "value"}}},
|
||||||
|
{"unicode": "test 🚀"},
|
||||||
|
{"numbers": 42, "boolean": True, "null": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
for original_params in test_cases:
|
||||||
|
encrypted = encrypt_system_oauth_params(original_params)
|
||||||
|
decrypted = decrypt_system_oauth_params(encrypted)
|
||||||
|
assert decrypted == original_params, f"Failed for case: {original_params}"
|
||||||
|
|
||||||
|
def test_convenience_functions_with_errors(self):
|
||||||
|
"""Test convenience functions with error conditions"""
|
||||||
|
# Test encryption with invalid input
|
||||||
|
with pytest.raises(Exception): # noqa: B017
|
||||||
|
encrypt_system_oauth_params(None) # type: ignore
|
||||||
|
|
||||||
|
# Test decryption with invalid input
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
decrypt_system_oauth_params("")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
decrypt_system_oauth_params(None) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""Test cases for error handling"""
|
||||||
|
|
||||||
|
def test_oauth_encryption_error_inheritance(self):
|
||||||
|
"""Test that OAuthEncryptionError is a proper exception"""
|
||||||
|
error = OAuthEncryptionError("Test error")
|
||||||
|
assert isinstance(error, Exception)
|
||||||
|
assert str(error) == "Test error"
|
||||||
|
|
||||||
|
def test_oauth_encryption_error_with_cause(self):
|
||||||
|
"""Test OAuthEncryptionError with cause"""
|
||||||
|
original_error = ValueError("Original error")
|
||||||
|
error = OAuthEncryptionError("Wrapper error")
|
||||||
|
error.__cause__ = original_error
|
||||||
|
|
||||||
|
assert isinstance(error, Exception)
|
||||||
|
assert str(error) == "Wrapper error"
|
||||||
|
assert error.__cause__ is original_error
|
||||||
|
|
||||||
|
def test_error_messages_are_informative(self):
|
||||||
|
"""Test that error messages are informative"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
|
||||||
|
# Test empty string error
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
encrypter.decrypt_oauth_params("")
|
||||||
|
assert "encrypted_data cannot be empty" in str(exc_info.value)
|
||||||
|
|
||||||
|
# Test non-string error
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
encrypter.decrypt_oauth_params(123) # type: ignore
|
||||||
|
assert "encrypted_data must be a string" in str(exc_info.value)
|
||||||
|
|
||||||
|
# Test invalid format error
|
||||||
|
short_data = base64.b64encode(b"short").decode()
|
||||||
|
with pytest.raises(OAuthEncryptionError) as exc_info:
|
||||||
|
encrypter.decrypt_oauth_params(short_data)
|
||||||
|
assert "Invalid encrypted data format" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Test cases for edge cases and boundary conditions"""
|
||||||
|
|
||||||
|
def test_very_long_secret_key(self):
|
||||||
|
"""Test with very long secret key"""
|
||||||
|
long_secret = "x" * 10000
|
||||||
|
encrypter = SystemOAuthEncrypter(long_secret)
|
||||||
|
|
||||||
|
# Key should still be 32 bytes due to SHA-256
|
||||||
|
assert len(encrypter.key) == 32
|
||||||
|
|
||||||
|
# Should still work normally
|
||||||
|
oauth_params = {"client_id": "test_id"}
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
assert decrypted == oauth_params
|
||||||
|
|
||||||
|
def test_special_characters_in_secret_key(self):
|
||||||
|
"""Test with special characters in secret key"""
|
||||||
|
special_secret = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~test🚀"
|
||||||
|
encrypter = SystemOAuthEncrypter(special_secret)
|
||||||
|
|
||||||
|
oauth_params = {"client_id": "test_id"}
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
assert decrypted == oauth_params
|
||||||
|
|
||||||
|
def test_empty_values_in_oauth_params(self):
|
||||||
|
"""Test with empty values in oauth params"""
|
||||||
|
oauth_params = {
|
||||||
|
"client_id": "",
|
||||||
|
"client_secret": "",
|
||||||
|
"empty_dict": {},
|
||||||
|
"empty_list": [],
|
||||||
|
"empty_string": "",
|
||||||
|
"zero": 0,
|
||||||
|
"false": False,
|
||||||
|
"none": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
assert decrypted == oauth_params
|
||||||
|
|
||||||
|
def test_deeply_nested_oauth_params(self):
|
||||||
|
"""Test with deeply nested oauth params"""
|
||||||
|
oauth_params = {"level1": {"level2": {"level3": {"level4": {"level5": {"deep_value": "found"}}}}}}
|
||||||
|
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
assert decrypted == oauth_params
|
||||||
|
|
||||||
|
def test_oauth_params_with_all_json_types(self):
|
||||||
|
"""Test with all JSON-supported data types"""
|
||||||
|
oauth_params = {
|
||||||
|
"string": "test_string",
|
||||||
|
"integer": 42,
|
||||||
|
"float": 3.14159,
|
||||||
|
"boolean_true": True,
|
||||||
|
"boolean_false": False,
|
||||||
|
"null_value": None,
|
||||||
|
"empty_string": "",
|
||||||
|
"array": [1, "two", 3.0, True, False, None],
|
||||||
|
"object": {"nested_string": "nested_value", "nested_number": 123, "nested_bool": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
assert decrypted == oauth_params
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformance:
|
||||||
|
"""Test cases for performance considerations"""
|
||||||
|
|
||||||
|
def test_large_oauth_params(self):
|
||||||
|
"""Test with large oauth params"""
|
||||||
|
large_value = "x" * 100000 # 100KB
|
||||||
|
oauth_params = {"client_id": "test_id", "large_data": large_value}
|
||||||
|
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
assert decrypted == oauth_params
|
||||||
|
|
||||||
|
def test_many_fields_oauth_params(self):
|
||||||
|
"""Test with many fields in oauth params"""
|
||||||
|
oauth_params = {f"field_{i}": f"value_{i}" for i in range(1000)}
|
||||||
|
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
assert decrypted == oauth_params
|
||||||
|
|
||||||
|
def test_repeated_encryption_decryption(self):
|
||||||
|
"""Test repeated encryption and decryption operations"""
|
||||||
|
encrypter = SystemOAuthEncrypter("test_secret")
|
||||||
|
oauth_params = {"client_id": "test_id", "client_secret": "test_secret"}
|
||||||
|
|
||||||
|
# Test multiple rounds of encryption/decryption
|
||||||
|
for i in range(100):
|
||||||
|
encrypted = encrypter.encrypt_oauth_params(oauth_params)
|
||||||
|
decrypted = encrypter.decrypt_oauth_params(encrypted)
|
||||||
|
assert decrypted == oauth_params
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
isValidElement,
|
||||||
|
memo,
|
||||||
|
useMemo,
|
||||||
|
} from 'react'
|
||||||
|
import type { AnyFieldApi } from '@tanstack/react-form'
|
||||||
|
import { useStore } from '@tanstack/react-form'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import Input from '@/app/components/base/input'
|
||||||
|
import PureSelect from '@/app/components/base/select/pure'
|
||||||
|
import type { FormSchema } from '@/app/components/base/form/types'
|
||||||
|
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||||
|
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||||
|
|
||||||
|
export type BaseFieldProps = {
|
||||||
|
fieldClassName?: string
|
||||||
|
labelClassName?: string
|
||||||
|
inputContainerClassName?: string
|
||||||
|
inputClassName?: string
|
||||||
|
formSchema: FormSchema
|
||||||
|
field: AnyFieldApi
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
const BaseField = ({
|
||||||
|
fieldClassName,
|
||||||
|
labelClassName,
|
||||||
|
inputContainerClassName,
|
||||||
|
inputClassName,
|
||||||
|
formSchema,
|
||||||
|
field,
|
||||||
|
disabled,
|
||||||
|
}: BaseFieldProps) => {
|
||||||
|
const renderI18nObject = useRenderI18nObject()
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
labelClassName: formLabelClassName,
|
||||||
|
show_on = [],
|
||||||
|
} = formSchema
|
||||||
|
|
||||||
|
const memorizedLabel = useMemo(() => {
|
||||||
|
if (isValidElement(label))
|
||||||
|
return label
|
||||||
|
|
||||||
|
if (typeof label === 'string')
|
||||||
|
return label
|
||||||
|
|
||||||
|
if (typeof label === 'object' && label !== null)
|
||||||
|
return renderI18nObject(label as Record<string, string>)
|
||||||
|
}, [label, renderI18nObject])
|
||||||
|
const memorizedPlaceholder = useMemo(() => {
|
||||||
|
if (typeof placeholder === 'string')
|
||||||
|
return placeholder
|
||||||
|
|
||||||
|
if (typeof placeholder === 'object' && placeholder !== null)
|
||||||
|
return renderI18nObject(placeholder as Record<string, string>)
|
||||||
|
}, [placeholder, renderI18nObject])
|
||||||
|
const memorizedOptions = useMemo(() => {
|
||||||
|
return options?.map((option) => {
|
||||||
|
return {
|
||||||
|
label: typeof option.label === 'string' ? option.label : renderI18nObject(option.label),
|
||||||
|
value: option.value,
|
||||||
|
}
|
||||||
|
}) || []
|
||||||
|
}, [options, renderI18nObject])
|
||||||
|
const value = useStore(field.form.store, s => s.values[field.name])
|
||||||
|
const values = useStore(field.form.store, (s) => {
|
||||||
|
return show_on.reduce((acc, condition) => {
|
||||||
|
acc[condition.variable] = s.values[condition.variable]
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any>)
|
||||||
|
})
|
||||||
|
const show = useMemo(() => {
|
||||||
|
return show_on.every((condition) => {
|
||||||
|
const conditionValue = values[condition.variable]
|
||||||
|
return conditionValue === condition.value
|
||||||
|
})
|
||||||
|
}, [values, show_on])
|
||||||
|
|
||||||
|
if (!show)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(fieldClassName)}>
|
||||||
|
<div className={cn(labelClassName, formLabelClassName)}>
|
||||||
|
{memorizedLabel}
|
||||||
|
{
|
||||||
|
required && !isValidElement(label) && (
|
||||||
|
<span className='ml-1 text-text-destructive-secondary'>*</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className={cn(inputContainerClassName)}>
|
||||||
|
{
|
||||||
|
formSchema.type === FormTypeEnum.textInput && (
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
className={cn(inputClassName)}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={memorizedPlaceholder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
formSchema.type === FormTypeEnum.secretInput && (
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
type='password'
|
||||||
|
className={cn(inputClassName)}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={memorizedPlaceholder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
formSchema.type === FormTypeEnum.textNumber && (
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
type='number'
|
||||||
|
className={cn(inputClassName)}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={memorizedPlaceholder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
formSchema.type === FormTypeEnum.select && (
|
||||||
|
<PureSelect
|
||||||
|
value={value}
|
||||||
|
onChange={v => field.handleChange(v)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={memorizedPlaceholder}
|
||||||
|
options={memorizedOptions}
|
||||||
|
triggerPopupSameWidth
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
formSchema.type === FormTypeEnum.radio && (
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
{
|
||||||
|
memorizedOptions.map(option => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 grow cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
|
||||||
|
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
|
||||||
|
)}
|
||||||
|
onClick={() => field.handleChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(BaseField)
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
} from 'react'
|
||||||
|
import type {
|
||||||
|
AnyFieldApi,
|
||||||
|
AnyFormApi,
|
||||||
|
} from '@tanstack/react-form'
|
||||||
|
import { useForm } from '@tanstack/react-form'
|
||||||
|
import type {
|
||||||
|
FormRef,
|
||||||
|
FormSchema,
|
||||||
|
} from '@/app/components/base/form/types'
|
||||||
|
import {
|
||||||
|
BaseField,
|
||||||
|
} from '.'
|
||||||
|
import type {
|
||||||
|
BaseFieldProps,
|
||||||
|
} from '.'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import {
|
||||||
|
useGetFormValues,
|
||||||
|
useGetValidators,
|
||||||
|
} from '@/app/components/base/form/hooks'
|
||||||
|
|
||||||
|
export type BaseFormProps = {
|
||||||
|
formSchemas?: FormSchema[]
|
||||||
|
defaultValues?: Record<string, any>
|
||||||
|
formClassName?: string
|
||||||
|
ref?: FormRef
|
||||||
|
disabled?: boolean
|
||||||
|
formFromProps?: AnyFormApi
|
||||||
|
} & Pick<BaseFieldProps, 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName'>
|
||||||
|
|
||||||
|
const BaseForm = ({
|
||||||
|
formSchemas = [],
|
||||||
|
defaultValues,
|
||||||
|
formClassName,
|
||||||
|
fieldClassName,
|
||||||
|
labelClassName,
|
||||||
|
inputContainerClassName,
|
||||||
|
inputClassName,
|
||||||
|
ref,
|
||||||
|
disabled,
|
||||||
|
formFromProps,
|
||||||
|
}: BaseFormProps) => {
|
||||||
|
const formFromHook = useForm({
|
||||||
|
defaultValues,
|
||||||
|
})
|
||||||
|
const form: any = formFromProps || formFromHook
|
||||||
|
const { getFormValues } = useGetFormValues(form, formSchemas)
|
||||||
|
const { getValidators } = useGetValidators()
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
return {
|
||||||
|
getForm() {
|
||||||
|
return form
|
||||||
|
},
|
||||||
|
getFormValues: (option) => {
|
||||||
|
return getFormValues(option)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [form, getFormValues])
|
||||||
|
|
||||||
|
const renderField = useCallback((field: AnyFieldApi) => {
|
||||||
|
const formSchema = formSchemas?.find(schema => schema.name === field.name)
|
||||||
|
|
||||||
|
if (formSchema) {
|
||||||
|
return (
|
||||||
|
<BaseField
|
||||||
|
field={field}
|
||||||
|
formSchema={formSchema}
|
||||||
|
fieldClassName={fieldClassName}
|
||||||
|
labelClassName={labelClassName}
|
||||||
|
inputContainerClassName={inputContainerClassName}
|
||||||
|
inputClassName={inputClassName}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled])
|
||||||
|
|
||||||
|
const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
|
||||||
|
const validators = getValidators(formSchema)
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
} = formSchema
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form.Field
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
validators={validators}
|
||||||
|
>
|
||||||
|
{renderField}
|
||||||
|
</form.Field>
|
||||||
|
)
|
||||||
|
}, [renderField, form, getValidators])
|
||||||
|
|
||||||
|
if (!formSchemas?.length)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={cn(formClassName)}
|
||||||
|
>
|
||||||
|
{formSchemas.map(renderFieldWrapper)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(BaseForm)
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export { default as BaseForm, type BaseFormProps } from './base-form'
|
||||||
|
export { default as BaseField, type BaseFieldProps } from './base-field'
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { BaseForm } from '../../components/base'
|
||||||
|
import type { BaseFormProps } from '../../components/base'
|
||||||
|
|
||||||
|
const AuthForm = ({
|
||||||
|
formSchemas = [],
|
||||||
|
defaultValues,
|
||||||
|
ref,
|
||||||
|
formFromProps,
|
||||||
|
}: BaseFormProps) => {
|
||||||
|
return (
|
||||||
|
<BaseForm
|
||||||
|
ref={ref}
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
defaultValues={defaultValues}
|
||||||
|
formClassName='space-y-4'
|
||||||
|
labelClassName='h-6 flex items-center mb-1 system-sm-medium text-text-secondary'
|
||||||
|
formFromProps={formFromProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(AuthForm)
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export * from './use-check-validated'
|
||||||
|
export * from './use-get-form-values'
|
||||||
|
export * from './use-get-validators'
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import type { AnyFormApi } from '@tanstack/react-form'
|
||||||
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
|
import type { FormSchema } from '@/app/components/base/form/types'
|
||||||
|
|
||||||
|
export const useCheckValidated = (form: AnyFormApi, FormSchemas: FormSchema[]) => {
|
||||||
|
const { notify } = useToastContext()
|
||||||
|
|
||||||
|
const checkValidated = useCallback(() => {
|
||||||
|
const allError = form?.getAllErrors()
|
||||||
|
const values = form.state.values
|
||||||
|
|
||||||
|
if (allError) {
|
||||||
|
const fields = allError.fields
|
||||||
|
const errorArray = Object.keys(fields).reduce((acc: string[], key: string) => {
|
||||||
|
const currentSchema = FormSchemas.find(schema => schema.name === key)
|
||||||
|
const { show_on = [] } = currentSchema || {}
|
||||||
|
const showOnValues = show_on.reduce((acc, condition) => {
|
||||||
|
acc[condition.variable] = values[condition.variable]
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any>)
|
||||||
|
const show = show_on?.every((condition) => {
|
||||||
|
const conditionValue = showOnValues[condition.variable]
|
||||||
|
return conditionValue === condition.value
|
||||||
|
})
|
||||||
|
const errors: any[] = show ? fields[key].errors : []
|
||||||
|
|
||||||
|
return [...acc, ...errors]
|
||||||
|
}, [] as string[])
|
||||||
|
|
||||||
|
if (errorArray.length) {
|
||||||
|
notify({
|
||||||
|
type: 'error',
|
||||||
|
message: errorArray[0],
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}, [form, notify, FormSchemas])
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkValidated,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import type { AnyFormApi } from '@tanstack/react-form'
|
||||||
|
import { useCheckValidated } from './use-check-validated'
|
||||||
|
import type {
|
||||||
|
FormSchema,
|
||||||
|
GetValuesOptions,
|
||||||
|
} from '../types'
|
||||||
|
import { getTransformedValuesWhenSecretInputPristine } from '../utils'
|
||||||
|
|
||||||
|
export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => {
|
||||||
|
const { checkValidated } = useCheckValidated(form, formSchemas)
|
||||||
|
|
||||||
|
const getFormValues = useCallback((
|
||||||
|
{
|
||||||
|
needCheckValidatedValues,
|
||||||
|
needTransformWhenSecretFieldIsPristine,
|
||||||
|
}: GetValuesOptions,
|
||||||
|
) => {
|
||||||
|
const values = form?.store.state.values || {}
|
||||||
|
if (!needCheckValidatedValues) {
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
isCheckValidated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkValidated()) {
|
||||||
|
return {
|
||||||
|
values: needTransformWhenSecretFieldIsPristine ? getTransformedValuesWhenSecretInputPristine(formSchemas, form) : values,
|
||||||
|
isCheckValidated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
values: {},
|
||||||
|
isCheckValidated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [form, checkValidated, formSchemas])
|
||||||
|
|
||||||
|
return {
|
||||||
|
getFormValues,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { FormSchema } from '../types'
|
||||||
|
|
||||||
|
export const useGetValidators = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const getValidators = useCallback((formSchema: FormSchema) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
validators,
|
||||||
|
required,
|
||||||
|
} = formSchema
|
||||||
|
let mergedValidators = validators
|
||||||
|
if (required && !validators) {
|
||||||
|
mergedValidators = {
|
||||||
|
onMount: ({ value }: any) => {
|
||||||
|
if (!value)
|
||||||
|
return t('common.errorMsg.fieldRequired', { field: name })
|
||||||
|
},
|
||||||
|
onChange: ({ value }: any) => {
|
||||||
|
if (!value)
|
||||||
|
return t('common.errorMsg.fieldRequired', { field: name })
|
||||||
|
},
|
||||||
|
onBlur: ({ value }: any) => {
|
||||||
|
if (!value)
|
||||||
|
return t('common.errorMsg.fieldRequired', { field: name })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergedValidators
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
return {
|
||||||
|
getValidators,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import type {
|
||||||
|
ForwardedRef,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import type {
|
||||||
|
AnyFormApi,
|
||||||
|
FieldValidators,
|
||||||
|
} from '@tanstack/react-form'
|
||||||
|
|
||||||
|
export type TypeWithI18N<T = string> = {
|
||||||
|
en_US: T
|
||||||
|
zh_Hans: T
|
||||||
|
[key: string]: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormShowOnObject = {
|
||||||
|
variable: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FormTypeEnum {
|
||||||
|
textInput = 'text-input',
|
||||||
|
textNumber = 'number-input',
|
||||||
|
secretInput = 'secret-input',
|
||||||
|
select = 'select',
|
||||||
|
radio = 'radio',
|
||||||
|
boolean = 'boolean',
|
||||||
|
files = 'files',
|
||||||
|
file = 'file',
|
||||||
|
modelSelector = 'model-selector',
|
||||||
|
toolSelector = 'tool-selector',
|
||||||
|
multiToolSelector = 'array[tools]',
|
||||||
|
appSelector = 'app-selector',
|
||||||
|
dynamicSelect = 'dynamic-select',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormOption = {
|
||||||
|
label: TypeWithI18N | string
|
||||||
|
value: string
|
||||||
|
show_on?: FormShowOnObject[]
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnyValidators = FieldValidators<any, any, any, any, any, any, any, any, any, any>
|
||||||
|
|
||||||
|
export type FormSchema = {
|
||||||
|
type: FormTypeEnum
|
||||||
|
name: string
|
||||||
|
label: string | ReactNode | TypeWithI18N
|
||||||
|
required: boolean
|
||||||
|
default?: any
|
||||||
|
tooltip?: string | TypeWithI18N
|
||||||
|
show_on?: FormShowOnObject[]
|
||||||
|
url?: string
|
||||||
|
scope?: string
|
||||||
|
help?: string | TypeWithI18N
|
||||||
|
placeholder?: string | TypeWithI18N
|
||||||
|
options?: FormOption[]
|
||||||
|
labelClassName?: string
|
||||||
|
validators?: AnyValidators
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormValues = Record<string, any>
|
||||||
|
|
||||||
|
export type GetValuesOptions = {
|
||||||
|
needTransformWhenSecretFieldIsPristine?: boolean
|
||||||
|
needCheckValidatedValues?: boolean
|
||||||
|
}
|
||||||
|
export type FormRefObject = {
|
||||||
|
getForm: () => AnyFormApi
|
||||||
|
getFormValues: (obj: GetValuesOptions) => {
|
||||||
|
values: Record<string, any>
|
||||||
|
isCheckValidated: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type FormRef = ForwardedRef<FormRefObject>
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from './secret-input'
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import type { AnyFormApi } from '@tanstack/react-form'
|
||||||
|
import type { FormSchema } from '@/app/components/base/form/types'
|
||||||
|
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||||
|
|
||||||
|
export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record<string, any>) => {
|
||||||
|
const transformedValues: Record<string, any> = { ...values }
|
||||||
|
|
||||||
|
isPristineSecretInputNames.forEach((name) => {
|
||||||
|
if (transformedValues[name])
|
||||||
|
transformedValues[name] = '[__HIDDEN__]'
|
||||||
|
})
|
||||||
|
|
||||||
|
return transformedValues
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTransformedValuesWhenSecretInputPristine = (formSchemas: FormSchema[], form: AnyFormApi) => {
|
||||||
|
const values = form?.store.state.values || {}
|
||||||
|
const isPristineSecretInputNames: string[] = []
|
||||||
|
for (let i = 0; i < formSchemas.length; i++) {
|
||||||
|
const schema = formSchemas[i]
|
||||||
|
if (schema.type === FormTypeEnum.secretInput) {
|
||||||
|
const fieldMeta = form?.getFieldMeta(schema.name)
|
||||||
|
if (fieldMeta?.isPristine)
|
||||||
|
isPristineSecretInputNames.push(schema.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformFormSchemasSecretInput(isPristineSecretInputNames, values)
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import {
|
||||||
|
PortalToFollowElem,
|
||||||
|
PortalToFollowElemContent,
|
||||||
|
} from '@/app/components/base/portal-to-follow-elem'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import type { ButtonProps } from '@/app/components/base/button'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
onClose?: () => void
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
title: string
|
||||||
|
subTitle?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
confirmButtonText?: string
|
||||||
|
onConfirm?: () => void
|
||||||
|
cancelButtonText?: string
|
||||||
|
onCancel?: () => void
|
||||||
|
showExtraButton?: boolean
|
||||||
|
extraButtonText?: string
|
||||||
|
extraButtonVariant?: ButtonProps['variant']
|
||||||
|
onExtraButtonClick?: () => void
|
||||||
|
footerSlot?: React.ReactNode
|
||||||
|
bottomSlot?: React.ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
const Modal = ({
|
||||||
|
onClose,
|
||||||
|
size = 'sm',
|
||||||
|
title,
|
||||||
|
subTitle,
|
||||||
|
children,
|
||||||
|
confirmButtonText,
|
||||||
|
onConfirm,
|
||||||
|
cancelButtonText,
|
||||||
|
onCancel,
|
||||||
|
showExtraButton,
|
||||||
|
extraButtonVariant = 'warning',
|
||||||
|
extraButtonText,
|
||||||
|
onExtraButtonClick,
|
||||||
|
footerSlot,
|
||||||
|
bottomSlot,
|
||||||
|
disabled,
|
||||||
|
}: ModalProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalToFollowElem open>
|
||||||
|
<PortalToFollowElemContent
|
||||||
|
className='z-[9998] flex h-full w-full items-center justify-center bg-background-overlay'
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-h-[80%] w-[480px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs',
|
||||||
|
size === 'sm' && 'w-[480px',
|
||||||
|
size === 'md' && 'w-[640px]',
|
||||||
|
)}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='title-2xl-semi-bold relative p-6 pb-3 pr-14 text-text-primary'>
|
||||||
|
{title}
|
||||||
|
{
|
||||||
|
subTitle && (
|
||||||
|
<div className='system-xs-regular mt-1 text-text-tertiary'>
|
||||||
|
{subTitle}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
className='absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
children && (
|
||||||
|
<div className='px-6 py-3'>{children}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div className='flex justify-between p-6 pt-5'>
|
||||||
|
<div>
|
||||||
|
{footerSlot}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center'>
|
||||||
|
{
|
||||||
|
showExtraButton && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={extraButtonVariant}
|
||||||
|
onClick={onExtraButtonClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{extraButtonText || t('common.operation.remove')}
|
||||||
|
</Button>
|
||||||
|
<div className='mx-3 h-4 w-[1px] bg-divider-regular'></div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<Button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{cancelButtonText || t('common.operation.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='ml-2'
|
||||||
|
variant='primary'
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{confirmButtonText || t('common.operation.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bottomSlot}
|
||||||
|
</div>
|
||||||
|
</PortalToFollowElemContent>
|
||||||
|
</PortalToFollowElem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Modal)
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import type { ButtonProps } from '@/app/components/base/button'
|
||||||
|
import ApiKeyModal from './api-key-modal'
|
||||||
|
import type { PluginPayload } from '../types'
|
||||||
|
|
||||||
|
export type AddApiKeyButtonProps = {
|
||||||
|
pluginPayload: PluginPayload
|
||||||
|
buttonVariant?: ButtonProps['variant']
|
||||||
|
buttonText?: string
|
||||||
|
disabled?: boolean
|
||||||
|
onUpdate?: () => void
|
||||||
|
}
|
||||||
|
const AddApiKeyButton = ({
|
||||||
|
pluginPayload,
|
||||||
|
buttonVariant = 'secondary-accent',
|
||||||
|
buttonText = 'use api key',
|
||||||
|
disabled,
|
||||||
|
onUpdate,
|
||||||
|
}: AddApiKeyButtonProps) => {
|
||||||
|
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className='w-full'
|
||||||
|
variant={buttonVariant}
|
||||||
|
onClick={() => setIsApiKeyModalOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
{
|
||||||
|
isApiKeyModalOpen && (
|
||||||
|
<ApiKeyModal
|
||||||
|
pluginPayload={pluginPayload}
|
||||||
|
onClose={() => setIsApiKeyModalOpen(false)}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(AddApiKeyButton)
|
||||||
@ -0,0 +1,259 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
RiClipboardLine,
|
||||||
|
RiEqualizer2Line,
|
||||||
|
RiInformation2Fill,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import type { ButtonProps } from '@/app/components/base/button'
|
||||||
|
import OAuthClientSettings from './oauth-client-settings'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import type { PluginPayload } from '../types'
|
||||||
|
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||||
|
import Badge from '@/app/components/base/badge'
|
||||||
|
import {
|
||||||
|
useGetPluginOAuthClientSchemaHook,
|
||||||
|
useGetPluginOAuthUrlHook,
|
||||||
|
} from '../hooks/use-credential'
|
||||||
|
import type { FormSchema } from '@/app/components/base/form/types'
|
||||||
|
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||||
|
import ActionButton from '@/app/components/base/action-button'
|
||||||
|
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||||
|
|
||||||
|
export type AddOAuthButtonProps = {
|
||||||
|
pluginPayload: PluginPayload
|
||||||
|
buttonVariant?: ButtonProps['variant']
|
||||||
|
buttonText?: string
|
||||||
|
className?: string
|
||||||
|
buttonLeftClassName?: string
|
||||||
|
buttonRightClassName?: string
|
||||||
|
dividerClassName?: string
|
||||||
|
disabled?: boolean
|
||||||
|
onUpdate?: () => void
|
||||||
|
}
|
||||||
|
const AddOAuthButton = ({
|
||||||
|
pluginPayload,
|
||||||
|
buttonVariant = 'primary',
|
||||||
|
buttonText = 'use oauth',
|
||||||
|
className,
|
||||||
|
buttonLeftClassName,
|
||||||
|
buttonRightClassName,
|
||||||
|
dividerClassName,
|
||||||
|
disabled,
|
||||||
|
onUpdate,
|
||||||
|
}: AddOAuthButtonProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const renderI18nObject = useRenderI18nObject()
|
||||||
|
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
|
||||||
|
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
|
||||||
|
const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload)
|
||||||
|
const {
|
||||||
|
schema = [],
|
||||||
|
is_oauth_custom_client_enabled,
|
||||||
|
is_system_oauth_params_exists,
|
||||||
|
client_params,
|
||||||
|
redirect_uri,
|
||||||
|
} = data || {}
|
||||||
|
const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled
|
||||||
|
const handleOAuth = useCallback(async () => {
|
||||||
|
const { authorization_url } = await getPluginOAuthUrl()
|
||||||
|
|
||||||
|
if (authorization_url) {
|
||||||
|
openOAuthPopup(
|
||||||
|
authorization_url,
|
||||||
|
() => onUpdate?.(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [getPluginOAuthUrl, onUpdate])
|
||||||
|
|
||||||
|
const renderCustomLabel = useCallback((item: FormSchema) => {
|
||||||
|
return (
|
||||||
|
<div className='w-full'>
|
||||||
|
<div className='mb-4 flex rounded-xl bg-background-section-burn p-4'>
|
||||||
|
<div className='mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg'>
|
||||||
|
<RiInformation2Fill className='h-5 w-5 text-text-accent' />
|
||||||
|
</div>
|
||||||
|
<div className='w-0 grow'>
|
||||||
|
<div className='system-sm-regular mb-1.5'>
|
||||||
|
{t('plugin.auth.clientInfo')}
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
redirect_uri && (
|
||||||
|
<div className='system-sm-medium flex w-full py-0.5'>
|
||||||
|
<div className='w-0 grow break-words'>{redirect_uri}</div>
|
||||||
|
<ActionButton
|
||||||
|
className='shrink-0'
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(redirect_uri || '')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiClipboardLine className='h-4 w-4' />
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='system-sm-medium flex h-6 items-center text-text-secondary'>
|
||||||
|
{renderI18nObject(item.label as Record<string, string>)}
|
||||||
|
{
|
||||||
|
item.required && (
|
||||||
|
<span className='ml-1 text-text-destructive-secondary'>*</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [t, redirect_uri, renderI18nObject])
|
||||||
|
const memorizedSchemas = useMemo(() => {
|
||||||
|
const result: FormSchema[] = schema.map((item, index) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
label: index === 0 ? renderCustomLabel(item) : item.label,
|
||||||
|
labelClassName: index === 0 ? 'h-auto' : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (is_system_oauth_params_exists) {
|
||||||
|
result.unshift({
|
||||||
|
name: '__oauth_client__',
|
||||||
|
label: t('plugin.auth.oauthClient'),
|
||||||
|
type: FormTypeEnum.radio,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: t('plugin.auth.default'),
|
||||||
|
value: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugin.auth.custom'),
|
||||||
|
value: 'custom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
required: false,
|
||||||
|
default: is_oauth_custom_client_enabled ? 'custom' : 'default',
|
||||||
|
} as FormSchema)
|
||||||
|
result.forEach((item, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
item.show_on = [
|
||||||
|
{
|
||||||
|
variable: '__oauth_client__',
|
||||||
|
value: 'custom',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (client_params)
|
||||||
|
item.default = client_params[item.name] || item.default
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [schema, renderCustomLabel, t, is_system_oauth_params_exists, is_oauth_custom_client_enabled, client_params])
|
||||||
|
|
||||||
|
const __auth_client__ = useMemo(() => {
|
||||||
|
if (isConfigured) {
|
||||||
|
if (is_oauth_custom_client_enabled)
|
||||||
|
return 'custom'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (is_system_oauth_params_exists)
|
||||||
|
return 'default'
|
||||||
|
return 'custom'
|
||||||
|
}
|
||||||
|
}, [isConfigured, is_oauth_custom_client_enabled, is_system_oauth_params_exists])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
isConfigured && (
|
||||||
|
<Button
|
||||||
|
variant={buttonVariant}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-0 py-0 hover:bg-components-button-primary-bg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleOAuth}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'flex h-full w-0 grow items-center justify-center rounded-l-lg pl-0.5 hover:bg-components-button-primary-bg-hover',
|
||||||
|
buttonLeftClassName,
|
||||||
|
)}>
|
||||||
|
<div
|
||||||
|
className='truncate'
|
||||||
|
title={buttonText}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
is_oauth_custom_client_enabled && (
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'ml-1 mr-0.5',
|
||||||
|
buttonVariant === 'primary' && 'border-text-primary-on-surface bg-components-badge-bg-dimm text-text-primary-on-surface',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('plugin.auth.custom')}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
'h-4 w-[1px] shrink-0 bg-text-primary-on-surface opacity-[0.15]',
|
||||||
|
dividerClassName,
|
||||||
|
)}></div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg hover:bg-components-button-primary-bg-hover',
|
||||||
|
buttonRightClassName,
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsOAuthSettingsOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiEqualizer2Line className='h-4 w-4' />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!isConfigured && (
|
||||||
|
<Button
|
||||||
|
variant={buttonVariant}
|
||||||
|
onClick={() => setIsOAuthSettingsOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
<RiEqualizer2Line className='mr-0.5 h-4 w-4' />
|
||||||
|
{t('plugin.auth.setupOAuth')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
isOAuthSettingsOpen && (
|
||||||
|
<OAuthClientSettings
|
||||||
|
pluginPayload={pluginPayload}
|
||||||
|
onClose={() => setIsOAuthSettingsOpen(false)}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
schemas={memorizedSchemas}
|
||||||
|
onAuth={handleOAuth}
|
||||||
|
editValues={{
|
||||||
|
...client_params,
|
||||||
|
__oauth_client__: __auth_client__,
|
||||||
|
}}
|
||||||
|
hasOriginalClientParams={Object.keys(client_params || {}).length > 0}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(AddOAuthButton)
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiExternalLinkLine } from '@remixicon/react'
|
||||||
|
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||||
|
import Modal from '@/app/components/base/modal/modal'
|
||||||
|
import { CredentialTypeEnum } from '../types'
|
||||||
|
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||||
|
import type { FormRefObject } from '@/app/components/base/form/types'
|
||||||
|
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||||
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
|
import Loading from '@/app/components/base/loading'
|
||||||
|
import type { PluginPayload } from '../types'
|
||||||
|
import {
|
||||||
|
useAddPluginCredentialHook,
|
||||||
|
useGetPluginCredentialSchemaHook,
|
||||||
|
useUpdatePluginCredentialHook,
|
||||||
|
} from '../hooks/use-credential'
|
||||||
|
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||||
|
|
||||||
|
export type ApiKeyModalProps = {
|
||||||
|
pluginPayload: PluginPayload
|
||||||
|
onClose?: () => void
|
||||||
|
editValues?: Record<string, any>
|
||||||
|
onRemove?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
onUpdate?: () => void
|
||||||
|
}
|
||||||
|
const ApiKeyModal = ({
|
||||||
|
pluginPayload,
|
||||||
|
onClose,
|
||||||
|
editValues,
|
||||||
|
onRemove,
|
||||||
|
disabled,
|
||||||
|
onUpdate,
|
||||||
|
}: ApiKeyModalProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { notify } = useToastContext()
|
||||||
|
const [doingAction, setDoingAction] = useState(false)
|
||||||
|
const doingActionRef = useRef(doingAction)
|
||||||
|
const handleSetDoingAction = useCallback((value: boolean) => {
|
||||||
|
doingActionRef.current = value
|
||||||
|
setDoingAction(value)
|
||||||
|
}, [])
|
||||||
|
const { data = [], isLoading } = useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY)
|
||||||
|
const formSchemas = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: FormTypeEnum.textInput,
|
||||||
|
name: '__name__',
|
||||||
|
label: t('plugin.auth.authorizationName'),
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
...data,
|
||||||
|
]
|
||||||
|
}, [data, t])
|
||||||
|
const defaultValues = formSchemas.reduce((acc, schema) => {
|
||||||
|
if (schema.default)
|
||||||
|
acc[schema.name] = schema.default
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any>)
|
||||||
|
const helpField = formSchemas.find(schema => schema.url && schema.help)
|
||||||
|
const renderI18nObject = useRenderI18nObject()
|
||||||
|
const { mutateAsync: addPluginCredential } = useAddPluginCredentialHook(pluginPayload)
|
||||||
|
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
|
||||||
|
const formRef = useRef<FormRefObject>(null)
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (doingActionRef.current)
|
||||||
|
return
|
||||||
|
const {
|
||||||
|
isCheckValidated,
|
||||||
|
values,
|
||||||
|
} = formRef.current?.getFormValues({
|
||||||
|
needCheckValidatedValues: true,
|
||||||
|
needTransformWhenSecretFieldIsPristine: true,
|
||||||
|
}) || { isCheckValidated: false, values: {} }
|
||||||
|
if (!isCheckValidated)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
__name__,
|
||||||
|
__credential_id__,
|
||||||
|
...restValues
|
||||||
|
} = values
|
||||||
|
|
||||||
|
handleSetDoingAction(true)
|
||||||
|
if (editValues) {
|
||||||
|
await updatePluginCredential({
|
||||||
|
credentials: restValues,
|
||||||
|
credential_id: __credential_id__,
|
||||||
|
name: __name__ || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await addPluginCredential({
|
||||||
|
credentials: restValues,
|
||||||
|
type: CredentialTypeEnum.API_KEY,
|
||||||
|
name: __name__ || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('common.api.actionSuccess'),
|
||||||
|
})
|
||||||
|
|
||||||
|
onClose?.()
|
||||||
|
onUpdate?.()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
handleSetDoingAction(false)
|
||||||
|
}
|
||||||
|
}, [addPluginCredential, onClose, onUpdate, updatePluginCredential, notify, t, editValues, handleSetDoingAction])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
size='md'
|
||||||
|
title={t('plugin.auth.useApiAuth')}
|
||||||
|
subTitle={t('plugin.auth.useApiAuthDesc')}
|
||||||
|
onClose={onClose}
|
||||||
|
onCancel={onClose}
|
||||||
|
footerSlot={
|
||||||
|
helpField && (
|
||||||
|
<a
|
||||||
|
className='system-xs-regular mr-2 flex items-center py-2 text-text-accent'
|
||||||
|
href={helpField?.url}
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
<span className='break-all'>
|
||||||
|
{renderI18nObject(helpField?.help as any)}
|
||||||
|
</span>
|
||||||
|
<RiExternalLinkLine className='ml-1 h-3 w-3' />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
bottomSlot={
|
||||||
|
<div className='flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary'>
|
||||||
|
<Lock01 className='mr-1 h-3 w-3 text-text-tertiary' />
|
||||||
|
{t('common.modelProvider.encrypted.front')}
|
||||||
|
<a
|
||||||
|
className='mx-1 text-text-accent'
|
||||||
|
target='_blank' rel='noopener noreferrer'
|
||||||
|
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
|
||||||
|
>
|
||||||
|
PKCS1_OAEP
|
||||||
|
</a>
|
||||||
|
{t('common.modelProvider.encrypted.back')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
showExtraButton={!!editValues}
|
||||||
|
onExtraButtonClick={onRemove}
|
||||||
|
disabled={disabled || isLoading || doingAction}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
isLoading && (
|
||||||
|
<div className='flex h-40 items-center justify-center'>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!isLoading && !!data.length && (
|
||||||
|
<AuthForm
|
||||||
|
ref={formRef}
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
defaultValues={editValues || defaultValues}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ApiKeyModal)
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useMemo,
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import AddOAuthButton from './add-oauth-button'
|
||||||
|
import type { AddOAuthButtonProps } from './add-oauth-button'
|
||||||
|
import AddApiKeyButton from './add-api-key-button'
|
||||||
|
import type { AddApiKeyButtonProps } from './add-api-key-button'
|
||||||
|
import type { PluginPayload } from '../types'
|
||||||
|
|
||||||
|
type AuthorizeProps = {
|
||||||
|
pluginPayload: PluginPayload
|
||||||
|
theme?: 'primary' | 'secondary'
|
||||||
|
showDivider?: boolean
|
||||||
|
canOAuth?: boolean
|
||||||
|
canApiKey?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onUpdate?: () => void
|
||||||
|
}
|
||||||
|
const Authorize = ({
|
||||||
|
pluginPayload,
|
||||||
|
theme = 'primary',
|
||||||
|
showDivider = true,
|
||||||
|
canOAuth,
|
||||||
|
canApiKey,
|
||||||
|
disabled,
|
||||||
|
onUpdate,
|
||||||
|
}: AuthorizeProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => {
|
||||||
|
if (theme === 'secondary') {
|
||||||
|
return {
|
||||||
|
buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'),
|
||||||
|
buttonVariant: 'secondary',
|
||||||
|
className: 'hover:bg-components-button-secondary-bg',
|
||||||
|
buttonLeftClassName: 'hover:bg-components-button-secondary-bg-hover',
|
||||||
|
buttonRightClassName: 'hover:bg-components-button-secondary-bg-hover',
|
||||||
|
dividerClassName: 'bg-divider-regular opacity-100',
|
||||||
|
pluginPayload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'),
|
||||||
|
pluginPayload,
|
||||||
|
}
|
||||||
|
}, [canApiKey, theme, pluginPayload, t])
|
||||||
|
|
||||||
|
const apiKeyButtonProps: AddApiKeyButtonProps = useMemo(() => {
|
||||||
|
if (theme === 'secondary') {
|
||||||
|
return {
|
||||||
|
pluginPayload,
|
||||||
|
buttonVariant: 'secondary',
|
||||||
|
buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pluginPayload,
|
||||||
|
buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'),
|
||||||
|
buttonVariant: !canOAuth ? 'primary' : 'secondary-accent',
|
||||||
|
}
|
||||||
|
}, [canOAuth, theme, pluginPayload, t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='flex items-center space-x-1.5'>
|
||||||
|
{
|
||||||
|
canOAuth && (
|
||||||
|
<div className='min-w-0 flex-[1]'>
|
||||||
|
<AddOAuthButton
|
||||||
|
{...oAuthButtonProps}
|
||||||
|
disabled={disabled}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
showDivider && canOAuth && canApiKey && (
|
||||||
|
<div className='system-2xs-medium-uppercase flex shrink-0 flex-col items-center justify-between text-text-tertiary'>
|
||||||
|
<div className='h-2 w-[1px] bg-divider-subtle'></div>
|
||||||
|
or
|
||||||
|
<div className='h-2 w-[1px] bg-divider-subtle'></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
canApiKey && (
|
||||||
|
<div className='min-w-0 flex-[1]'>
|
||||||
|
<AddApiKeyButton
|
||||||
|
{...apiKeyButtonProps}
|
||||||
|
disabled={disabled}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Authorize)
|
||||||
@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { RiExternalLinkLine } from '@remixicon/react'
|
||||||
|
import {
|
||||||
|
useForm,
|
||||||
|
useStore,
|
||||||
|
} from '@tanstack/react-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Modal from '@/app/components/base/modal/modal'
|
||||||
|
import {
|
||||||
|
useDeletePluginOAuthCustomClientHook,
|
||||||
|
useInvalidPluginOAuthClientSchemaHook,
|
||||||
|
useSetPluginOAuthCustomClientHook,
|
||||||
|
} from '../hooks/use-credential'
|
||||||
|
import type { PluginPayload } from '../types'
|
||||||
|
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||||
|
import type {
|
||||||
|
FormRefObject,
|
||||||
|
FormSchema,
|
||||||
|
} from '@/app/components/base/form/types'
|
||||||
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||||
|
|
||||||
|
type OAuthClientSettingsProps = {
|
||||||
|
pluginPayload: PluginPayload
|
||||||
|
onClose?: () => void
|
||||||
|
editValues?: Record<string, any>
|
||||||
|
disabled?: boolean
|
||||||
|
schemas: FormSchema[]
|
||||||
|
onAuth?: () => Promise<void>
|
||||||
|
hasOriginalClientParams?: boolean
|
||||||
|
onUpdate?: () => void
|
||||||
|
}
|
||||||
|
const OAuthClientSettings = ({
|
||||||
|
pluginPayload,
|
||||||
|
onClose,
|
||||||
|
editValues,
|
||||||
|
disabled,
|
||||||
|
schemas,
|
||||||
|
onAuth,
|
||||||
|
hasOriginalClientParams,
|
||||||
|
onUpdate,
|
||||||
|
}: OAuthClientSettingsProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { notify } = useToastContext()
|
||||||
|
const [doingAction, setDoingAction] = useState(false)
|
||||||
|
const doingActionRef = useRef(doingAction)
|
||||||
|
const handleSetDoingAction = useCallback((value: boolean) => {
|
||||||
|
doingActionRef.current = value
|
||||||
|
setDoingAction(value)
|
||||||
|
}, [])
|
||||||
|
const defaultValues = schemas.reduce((acc, schema) => {
|
||||||
|
if (schema.default)
|
||||||
|
acc[schema.name] = schema.default
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any>)
|
||||||
|
const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload)
|
||||||
|
const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload)
|
||||||
|
const formRef = useRef<FormRefObject>(null)
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (doingActionRef.current)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
isCheckValidated,
|
||||||
|
values,
|
||||||
|
} = formRef.current?.getFormValues({
|
||||||
|
needCheckValidatedValues: true,
|
||||||
|
needTransformWhenSecretFieldIsPristine: true,
|
||||||
|
}) || { isCheckValidated: false, values: {} }
|
||||||
|
if (!isCheckValidated)
|
||||||
|
throw new Error('error')
|
||||||
|
const {
|
||||||
|
__oauth_client__,
|
||||||
|
...restValues
|
||||||
|
} = values
|
||||||
|
|
||||||
|
handleSetDoingAction(true)
|
||||||
|
await setPluginOAuthCustomClient({
|
||||||
|
client_params: restValues,
|
||||||
|
enable_oauth_custom_client: __oauth_client__ === 'custom',
|
||||||
|
})
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('common.api.actionSuccess'),
|
||||||
|
})
|
||||||
|
|
||||||
|
onClose?.()
|
||||||
|
onUpdate?.()
|
||||||
|
invalidPluginOAuthClientSchema()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
handleSetDoingAction(false)
|
||||||
|
}
|
||||||
|
}, [onClose, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, notify, t, handleSetDoingAction])
|
||||||
|
|
||||||
|
const handleConfirmAndAuthorize = useCallback(async () => {
|
||||||
|
await handleConfirm()
|
||||||
|
if (onAuth)
|
||||||
|
await onAuth()
|
||||||
|
}, [handleConfirm, onAuth])
|
||||||
|
const { mutateAsync: deletePluginOAuthCustomClient } = useDeletePluginOAuthCustomClientHook(pluginPayload)
|
||||||
|
const handleRemove = useCallback(async () => {
|
||||||
|
if (doingActionRef.current)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
handleSetDoingAction(true)
|
||||||
|
await deletePluginOAuthCustomClient()
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('common.api.actionSuccess'),
|
||||||
|
})
|
||||||
|
onClose?.()
|
||||||
|
onUpdate?.()
|
||||||
|
invalidPluginOAuthClientSchema()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
handleSetDoingAction(false)
|
||||||
|
}
|
||||||
|
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, notify, t, handleSetDoingAction, onClose])
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: editValues || defaultValues,
|
||||||
|
})
|
||||||
|
const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__)
|
||||||
|
const helpField = schemas.find(schema => schema.url && schema.help)
|
||||||
|
const renderI18nObject = useRenderI18nObject()
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('plugin.auth.oauthClientSettings')}
|
||||||
|
confirmButtonText={t('plugin.auth.saveAndAuth')}
|
||||||
|
cancelButtonText={t('plugin.auth.saveOnly')}
|
||||||
|
extraButtonText={t('common.operation.cancel')}
|
||||||
|
showExtraButton
|
||||||
|
extraButtonVariant='secondary'
|
||||||
|
onExtraButtonClick={onClose}
|
||||||
|
onClose={onClose}
|
||||||
|
onCancel={handleConfirm}
|
||||||
|
onConfirm={handleConfirmAndAuthorize}
|
||||||
|
disabled={disabled || doingAction}
|
||||||
|
footerSlot={
|
||||||
|
__oauth_client__ === 'custom' && hasOriginalClientParams && (
|
||||||
|
<div className='grow'>
|
||||||
|
<Button
|
||||||
|
variant='secondary'
|
||||||
|
className='text-components-button-destructive-secondary-text'
|
||||||
|
disabled={disabled || doingAction || !editValues}
|
||||||
|
onClick={handleRemove}
|
||||||
|
>
|
||||||
|
{t('common.operation.remove')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<AuthForm
|
||||||
|
formFromProps={form}
|
||||||
|
ref={formRef}
|
||||||
|
formSchemas={schemas}
|
||||||
|
defaultValues={editValues || defaultValues}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
helpField && __oauth_client__ === 'custom' && (
|
||||||
|
<a
|
||||||
|
className='system-xs-regular mt-4 flex items-center text-text-accent'
|
||||||
|
href={helpField?.url}
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
<span className='break-all'>
|
||||||
|
{renderI18nObject(helpField?.help as any)}
|
||||||
|
</span>
|
||||||
|
<RiExternalLinkLine className='ml-1 h-3 w-3' />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(OAuthClientSettings)
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiArrowDownSLine } from '@remixicon/react'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import Indicator from '@/app/components/header/indicator'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import type {
|
||||||
|
Credential,
|
||||||
|
PluginPayload,
|
||||||
|
} from './types'
|
||||||
|
import {
|
||||||
|
Authorized,
|
||||||
|
usePluginAuth,
|
||||||
|
} from '.'
|
||||||
|
|
||||||
|
type AuthorizedInNodeProps = {
|
||||||
|
pluginPayload: PluginPayload
|
||||||
|
onAuthorizationItemClick: (id: string) => void
|
||||||
|
credentialId?: string
|
||||||
|
}
|
||||||
|
const AuthorizedInNode = ({
|
||||||
|
pluginPayload,
|
||||||
|
onAuthorizationItemClick,
|
||||||
|
credentialId,
|
||||||
|
}: AuthorizedInNodeProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const {
|
||||||
|
canApiKey,
|
||||||
|
canOAuth,
|
||||||
|
credentials,
|
||||||
|
disabled,
|
||||||
|
invalidPluginCredentialInfo,
|
||||||
|
} = usePluginAuth(pluginPayload, isOpen || !!credentialId)
|
||||||
|
const renderTrigger = useCallback((open?: boolean) => {
|
||||||
|
let label = ''
|
||||||
|
let removed = false
|
||||||
|
if (!credentialId) {
|
||||||
|
label = t('plugin.auth.workspaceDefault')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const credential = credentials.find(c => c.id === credentialId)
|
||||||
|
label = credential ? credential.name : t('plugin.auth.authRemoved')
|
||||||
|
removed = !credential
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
className={cn(
|
||||||
|
open && !removed && 'bg-components-button-ghost-bg-hover',
|
||||||
|
removed && 'bg-transparent text-text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Indicator
|
||||||
|
className='mr-1.5'
|
||||||
|
color={removed ? 'red' : 'green'}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
<RiArrowDownSLine
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5 text-components-button-ghost-text',
|
||||||
|
removed && 'text-text-destructive',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}, [credentialId, credentials, t])
|
||||||
|
const extraAuthorizationItems: Credential[] = [
|
||||||
|
{
|
||||||
|
id: '__workspace_default__',
|
||||||
|
name: t('plugin.auth.workspaceDefault'),
|
||||||
|
provider: '',
|
||||||
|
is_default: !credentialId,
|
||||||
|
isWorkspaceDefault: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const handleAuthorizationItemClick = useCallback((id: string) => {
|
||||||
|
onAuthorizationItemClick(id)
|
||||||
|
setIsOpen(false)
|
||||||
|
}, [
|
||||||
|
onAuthorizationItemClick,
|
||||||
|
setIsOpen,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Authorized
|
||||||
|
pluginPayload={pluginPayload}
|
||||||
|
credentials={credentials}
|
||||||
|
canOAuth={canOAuth}
|
||||||
|
canApiKey={canApiKey}
|
||||||
|
renderTrigger={renderTrigger}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
offset={4}
|
||||||
|
placement='bottom-end'
|
||||||
|
triggerPopupSameWidth={false}
|
||||||
|
popupClassName='w-[360px]'
|
||||||
|
disabled={disabled}
|
||||||
|
disableSetDefault
|
||||||
|
onItemClick={handleAuthorizationItemClick}
|
||||||
|
extraAuthorizationItems={extraAuthorizationItems}
|
||||||
|
showItemSelectedIcon
|
||||||
|
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||||
|
onUpdate={invalidPluginCredentialInfo}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(AuthorizedInNode)
|
||||||
@ -0,0 +1,342 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
RiArrowDownSLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
PortalToFollowElem,
|
||||||
|
PortalToFollowElemContent,
|
||||||
|
PortalToFollowElemTrigger,
|
||||||
|
} from '@/app/components/base/portal-to-follow-elem'
|
||||||
|
import type {
|
||||||
|
PortalToFollowElemOptions,
|
||||||
|
} from '@/app/components/base/portal-to-follow-elem'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import Indicator from '@/app/components/header/indicator'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import Confirm from '@/app/components/base/confirm'
|
||||||
|
import Authorize from '../authorize'
|
||||||
|
import type { Credential } from '../types'
|
||||||
|
import { CredentialTypeEnum } from '../types'
|
||||||
|
import ApiKeyModal from '../authorize/api-key-modal'
|
||||||
|
import Item from './item'
|
||||||
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
|
import type { PluginPayload } from '../types'
|
||||||
|
import {
|
||||||
|
useDeletePluginCredentialHook,
|
||||||
|
useSetPluginDefaultCredentialHook,
|
||||||
|
useUpdatePluginCredentialHook,
|
||||||
|
} from '../hooks/use-credential'
|
||||||
|
|
||||||
|
type AuthorizedProps = {
|
||||||
|
pluginPayload: PluginPayload
|
||||||
|
credentials: Credential[]
|
||||||
|
canOAuth?: boolean
|
||||||
|
canApiKey?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
renderTrigger?: (open?: boolean) => React.ReactNode
|
||||||
|
isOpen?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
offset?: PortalToFollowElemOptions['offset']
|
||||||
|
placement?: PortalToFollowElemOptions['placement']
|
||||||
|
triggerPopupSameWidth?: boolean
|
||||||
|
popupClassName?: string
|
||||||
|
disableSetDefault?: boolean
|
||||||
|
onItemClick?: (id: string) => void
|
||||||
|
extraAuthorizationItems?: Credential[]
|
||||||
|
showItemSelectedIcon?: boolean
|
||||||
|
selectedCredentialId?: string
|
||||||
|
onUpdate?: () => void
|
||||||
|
}
|
||||||
|
const Authorized = ({
|
||||||
|
pluginPayload,
|
||||||
|
credentials,
|
||||||
|
canOAuth,
|
||||||
|
canApiKey,
|
||||||
|
disabled,
|
||||||
|
renderTrigger,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
offset = 8,
|
||||||
|
placement = 'bottom-start',
|
||||||
|
triggerPopupSameWidth = true,
|
||||||
|
popupClassName,
|
||||||
|
disableSetDefault,
|
||||||
|
onItemClick,
|
||||||
|
extraAuthorizationItems,
|
||||||
|
showItemSelectedIcon,
|
||||||
|
selectedCredentialId,
|
||||||
|
onUpdate,
|
||||||
|
}: AuthorizedProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { notify } = useToastContext()
|
||||||
|
const [isLocalOpen, setIsLocalOpen] = useState(false)
|
||||||
|
const mergedIsOpen = isOpen ?? isLocalOpen
|
||||||
|
const setMergedIsOpen = useCallback((open: boolean) => {
|
||||||
|
if (onOpenChange)
|
||||||
|
onOpenChange(open)
|
||||||
|
|
||||||
|
setIsLocalOpen(open)
|
||||||
|
}, [onOpenChange])
|
||||||
|
const oAuthCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.OAUTH2)
|
||||||
|
const apiKeyCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.API_KEY)
|
||||||
|
const pendingOperationCredentialId = useRef<string | null>(null)
|
||||||
|
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
|
||||||
|
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
|
||||||
|
const openConfirm = useCallback((credentialId?: string) => {
|
||||||
|
if (credentialId)
|
||||||
|
pendingOperationCredentialId.current = credentialId
|
||||||
|
|
||||||
|
setDeleteCredentialId(pendingOperationCredentialId.current)
|
||||||
|
}, [])
|
||||||
|
const closeConfirm = useCallback(() => {
|
||||||
|
setDeleteCredentialId(null)
|
||||||
|
pendingOperationCredentialId.current = null
|
||||||
|
}, [])
|
||||||
|
const [doingAction, setDoingAction] = useState(false)
|
||||||
|
const doingActionRef = useRef(doingAction)
|
||||||
|
const handleSetDoingAction = useCallback((doing: boolean) => {
|
||||||
|
doingActionRef.current = doing
|
||||||
|
setDoingAction(doing)
|
||||||
|
}, [])
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
if (doingActionRef.current)
|
||||||
|
return
|
||||||
|
if (!pendingOperationCredentialId.current) {
|
||||||
|
setDeleteCredentialId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
handleSetDoingAction(true)
|
||||||
|
await deletePluginCredential({ credential_id: pendingOperationCredentialId.current })
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('common.api.actionSuccess'),
|
||||||
|
})
|
||||||
|
onUpdate?.()
|
||||||
|
setDeleteCredentialId(null)
|
||||||
|
pendingOperationCredentialId.current = null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
handleSetDoingAction(false)
|
||||||
|
}
|
||||||
|
}, [deletePluginCredential, onUpdate, notify, t, handleSetDoingAction])
|
||||||
|
const [editValues, setEditValues] = useState<Record<string, any> | null>(null)
|
||||||
|
const handleEdit = useCallback((id: string, values: Record<string, any>) => {
|
||||||
|
pendingOperationCredentialId.current = id
|
||||||
|
setEditValues(values)
|
||||||
|
}, [])
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
setDeleteCredentialId(pendingOperationCredentialId.current)
|
||||||
|
}, [])
|
||||||
|
const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload)
|
||||||
|
const handleSetDefault = useCallback(async (id: string) => {
|
||||||
|
if (doingActionRef.current)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
handleSetDoingAction(true)
|
||||||
|
await setPluginDefaultCredential(id)
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('common.api.actionSuccess'),
|
||||||
|
})
|
||||||
|
onUpdate?.()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
handleSetDoingAction(false)
|
||||||
|
}
|
||||||
|
}, [setPluginDefaultCredential, onUpdate, notify, t, handleSetDoingAction])
|
||||||
|
const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload)
|
||||||
|
const handleRename = useCallback(async (payload: {
|
||||||
|
credential_id: string
|
||||||
|
name: string
|
||||||
|
}) => {
|
||||||
|
if (doingActionRef.current)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
handleSetDoingAction(true)
|
||||||
|
await updatePluginCredential(payload)
|
||||||
|
notify({
|
||||||
|
type: 'success',
|
||||||
|
message: t('common.api.actionSuccess'),
|
||||||
|
})
|
||||||
|
onUpdate?.()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
handleSetDoingAction(false)
|
||||||
|
}
|
||||||
|
}, [updatePluginCredential, notify, t, handleSetDoingAction, onUpdate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PortalToFollowElem
|
||||||
|
open={mergedIsOpen}
|
||||||
|
onOpenChange={setMergedIsOpen}
|
||||||
|
placement={placement}
|
||||||
|
offset={offset}
|
||||||
|
triggerPopupSameWidth={triggerPopupSameWidth}
|
||||||
|
>
|
||||||
|
<PortalToFollowElemTrigger
|
||||||
|
onClick={() => setMergedIsOpen(!mergedIsOpen)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
{
|
||||||
|
renderTrigger
|
||||||
|
? renderTrigger(mergedIsOpen)
|
||||||
|
: (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'w-full',
|
||||||
|
isOpen && 'bg-components-button-secondary-bg-hover',
|
||||||
|
)}>
|
||||||
|
<Indicator className='mr-2' />
|
||||||
|
{credentials.length}
|
||||||
|
{
|
||||||
|
credentials.length > 1
|
||||||
|
? t('plugin.auth.authorizations')
|
||||||
|
: t('plugin.auth.authorization')
|
||||||
|
}
|
||||||
|
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</PortalToFollowElemTrigger>
|
||||||
|
<PortalToFollowElemContent className='z-[100]'>
|
||||||
|
<div className={cn(
|
||||||
|
'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg',
|
||||||
|
popupClassName,
|
||||||
|
)}>
|
||||||
|
<div className='py-1'>
|
||||||
|
{
|
||||||
|
!!extraAuthorizationItems?.length && (
|
||||||
|
<div className='p-1'>
|
||||||
|
{
|
||||||
|
extraAuthorizationItems.map(credential => (
|
||||||
|
<Item
|
||||||
|
key={credential.id}
|
||||||
|
credential={credential}
|
||||||
|
disabled={disabled}
|
||||||
|
onItemClick={onItemClick}
|
||||||
|
disableRename
|
||||||
|
disableEdit
|
||||||
|
disableDelete
|
||||||
|
disableSetDefault
|
||||||
|
showSelectedIcon={showItemSelectedIcon}
|
||||||
|
selectedCredentialId={selectedCredentialId}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!!oAuthCredentials.length && (
|
||||||
|
<div className='p-1'>
|
||||||
|
<div className={cn(
|
||||||
|
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
|
||||||
|
showItemSelectedIcon && 'pl-7',
|
||||||
|
)}>
|
||||||
|
OAuth
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
oAuthCredentials.map(credential => (
|
||||||
|
<Item
|
||||||
|
key={credential.id}
|
||||||
|
credential={credential}
|
||||||
|
disabled={disabled}
|
||||||
|
disableEdit
|
||||||
|
onDelete={openConfirm}
|
||||||
|
onSetDefault={handleSetDefault}
|
||||||
|
onRename={handleRename}
|
||||||
|
disableSetDefault={disableSetDefault}
|
||||||
|
onItemClick={onItemClick}
|
||||||
|
showSelectedIcon={showItemSelectedIcon}
|
||||||
|
selectedCredentialId={selectedCredentialId}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!!apiKeyCredentials.length && (
|
||||||
|
<div className='p-1'>
|
||||||
|
<div className={cn(
|
||||||
|
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
|
||||||
|
showItemSelectedIcon && 'pl-7',
|
||||||
|
)}>
|
||||||
|
API Keys
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
apiKeyCredentials.map(credential => (
|
||||||
|
<Item
|
||||||
|
key={credential.id}
|
||||||
|
credential={credential}
|
||||||
|
disabled={disabled}
|
||||||
|
onDelete={openConfirm}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onSetDefault={handleSetDefault}
|
||||||
|
disableSetDefault={disableSetDefault}
|
||||||
|
disableRename
|
||||||
|
onItemClick={onItemClick}
|
||||||
|
onRename={handleRename}
|
||||||
|
showSelectedIcon={showItemSelectedIcon}
|
||||||
|
selectedCredentialId={selectedCredentialId}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className='h-[1px] bg-divider-subtle'></div>
|
||||||
|
<div className='p-2'>
|
||||||
|
<Authorize
|
||||||
|
pluginPayload={pluginPayload}
|
||||||
|
theme='secondary'
|
||||||
|
showDivider={false}
|
||||||
|
canOAuth={canOAuth}
|
||||||
|
canApiKey={canApiKey}
|
||||||
|
disabled={disabled}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PortalToFollowElemContent>
|
||||||
|
</PortalToFollowElem>
|
||||||
|
{
|
||||||
|
deleteCredentialId && (
|
||||||
|
<Confirm
|
||||||
|
isShow
|
||||||
|
title={t('datasetDocuments.list.delete.title')}
|
||||||
|
isDisabled={doingAction}
|
||||||
|
onCancel={closeConfirm}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!!editValues && (
|
||||||
|
<ApiKeyModal
|
||||||
|
pluginPayload={pluginPayload}
|
||||||
|
editValues={editValues}
|
||||||
|
onClose={() => {
|
||||||
|
setEditValues(null)
|
||||||
|
pendingOperationCredentialId.current = null
|
||||||
|
}}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
disabled={disabled || doingAction}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Authorized)
|
||||||
@ -0,0 +1,219 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
RiCheckLine,
|
||||||
|
RiDeleteBinLine,
|
||||||
|
RiEditLine,
|
||||||
|
RiEqualizer2Line,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import Indicator from '@/app/components/header/indicator'
|
||||||
|
import Badge from '@/app/components/base/badge'
|
||||||
|
import ActionButton from '@/app/components/base/action-button'
|
||||||
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import Input from '@/app/components/base/input'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import type { Credential } from '../types'
|
||||||
|
import { CredentialTypeEnum } from '../types'
|
||||||
|
|
||||||
|
type ItemProps = {
|
||||||
|
credential: Credential
|
||||||
|
disabled?: boolean
|
||||||
|
onDelete?: (id: string) => void
|
||||||
|
onEdit?: (id: string, values: Record<string, any>) => void
|
||||||
|
onSetDefault?: (id: string) => void
|
||||||
|
onRename?: (payload: {
|
||||||
|
credential_id: string
|
||||||
|
name: string
|
||||||
|
}) => void
|
||||||
|
disableRename?: boolean
|
||||||
|
disableEdit?: boolean
|
||||||
|
disableDelete?: boolean
|
||||||
|
disableSetDefault?: boolean
|
||||||
|
onItemClick?: (id: string) => void
|
||||||
|
showSelectedIcon?: boolean
|
||||||
|
selectedCredentialId?: string
|
||||||
|
}
|
||||||
|
const Item = ({
|
||||||
|
credential,
|
||||||
|
disabled,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
onSetDefault,
|
||||||
|
onRename,
|
||||||
|
disableRename,
|
||||||
|
disableEdit,
|
||||||
|
disableDelete,
|
||||||
|
disableSetDefault,
|
||||||
|
onItemClick,
|
||||||
|
showSelectedIcon,
|
||||||
|
selectedCredentialId,
|
||||||
|
}: ItemProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [renaming, setRenaming] = useState(false)
|
||||||
|
const [renameValue, setRenameValue] = useState(credential.name)
|
||||||
|
const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2
|
||||||
|
const showAction = useMemo(() => {
|
||||||
|
return !(disableRename && disableEdit && disableDelete && disableSetDefault)
|
||||||
|
}, [disableRename, disableEdit, disableDelete, disableSetDefault])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={credential.id}
|
||||||
|
className={cn(
|
||||||
|
'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover',
|
||||||
|
renaming && 'bg-state-base-hover',
|
||||||
|
)}
|
||||||
|
onClick={() => onItemClick?.(credential.id === '__workspace_default__' ? '' : credential.id)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
renaming && (
|
||||||
|
<div className='flex w-full items-center space-x-1'>
|
||||||
|
<Input
|
||||||
|
wrapperClassName='grow rounded-[6px]'
|
||||||
|
className='h-6'
|
||||||
|
value={renameValue}
|
||||||
|
onChange={e => setRenameValue(e.target.value)}
|
||||||
|
placeholder={t('common.placeholder.input')}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
variant='primary'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRename?.({
|
||||||
|
credential_id: credential.id,
|
||||||
|
name: renameValue,
|
||||||
|
})
|
||||||
|
setRenaming(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.operation.save')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setRenaming(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.operation.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!renaming && (
|
||||||
|
<div className='flex w-0 grow items-center space-x-1.5'>
|
||||||
|
{
|
||||||
|
showSelectedIcon && (
|
||||||
|
<div className='h-4 w-4'>
|
||||||
|
{
|
||||||
|
selectedCredentialId === credential.id && (
|
||||||
|
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<Indicator className='ml-2 mr-1.5 shrink-0' />
|
||||||
|
<div
|
||||||
|
className='system-md-regular truncate text-text-secondary'
|
||||||
|
title={credential.name}
|
||||||
|
>
|
||||||
|
{credential.name}
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
credential.is_default && (
|
||||||
|
<Badge className='shrink-0'>
|
||||||
|
{t('plugin.auth.default')}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
showAction && !renaming && (
|
||||||
|
<div className='ml-2 hidden shrink-0 items-center group-hover:flex'>
|
||||||
|
{
|
||||||
|
!credential.is_default && !disableSetDefault && (
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onSetDefault?.(credential.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('plugin.auth.setDefault')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!disableRename && (
|
||||||
|
<Tooltip popupContent={t('common.operation.rename')}>
|
||||||
|
<ActionButton
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setRenaming(true)
|
||||||
|
setRenameValue(credential.name)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiEditLine className='h-4 w-4 text-text-tertiary' />
|
||||||
|
</ActionButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!isOAuth && !disableEdit && (
|
||||||
|
<Tooltip popupContent={t('common.operation.edit')}>
|
||||||
|
<ActionButton
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onEdit?.(
|
||||||
|
credential.id,
|
||||||
|
{
|
||||||
|
...credential.credentials,
|
||||||
|
__name__: credential.name,
|
||||||
|
__credential_id__: credential.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||||
|
</ActionButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!disableDelete && (
|
||||||
|
<Tooltip popupContent={t('common.operation.delete')}>
|
||||||
|
<ActionButton
|
||||||
|
className='hover:bg-transparent'
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete?.(credential.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary hover:text-text-destructive' />
|
||||||
|
</ActionButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Item)
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
useAddPluginCredential,
|
||||||
|
useDeletePluginCredential,
|
||||||
|
useDeletePluginOAuthCustomClient,
|
||||||
|
useGetPluginCredentialInfo,
|
||||||
|
useGetPluginCredentialSchema,
|
||||||
|
useGetPluginOAuthClientSchema,
|
||||||
|
useGetPluginOAuthUrl,
|
||||||
|
useInvalidPluginCredentialInfo,
|
||||||
|
useInvalidPluginOAuthClientSchema,
|
||||||
|
useSetPluginDefaultCredential,
|
||||||
|
useSetPluginOAuthCustomClient,
|
||||||
|
useUpdatePluginCredential,
|
||||||
|
} from '@/service/use-plugins-auth'
|
||||||
|
import { useGetApi } from './use-get-api'
|
||||||
|
import type { PluginPayload } from '../types'
|
||||||
|
import type { CredentialTypeEnum } from '../types'
|
||||||
|
|
||||||
|
export const useGetPluginCredentialInfoHook = (pluginPayload: PluginPayload, enable?: boolean) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
return useGetPluginCredentialInfo(enable ? apiMap.getCredentialInfo : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeletePluginCredentialHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useDeletePluginCredential(apiMap.deleteCredential)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInvalidPluginCredentialInfoHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useInvalidPluginCredentialInfo(apiMap.getCredentialInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSetPluginDefaultCredentialHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useSetPluginDefaultCredential(apiMap.setDefaultCredential)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetPluginCredentialSchemaHook = (pluginPayload: PluginPayload, credentialType: CredentialTypeEnum) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useGetPluginCredentialSchema(apiMap.getCredentialSchema(credentialType))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAddPluginCredentialHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useAddPluginCredential(apiMap.addCredential)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdatePluginCredentialHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useUpdatePluginCredential(apiMap.updateCredential)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetPluginOAuthUrlHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useGetPluginOAuthUrl(apiMap.getOauthUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetPluginOAuthClientSchemaHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useGetPluginOAuthClientSchema(apiMap.getOauthClientSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInvalidPluginOAuthClientSchemaHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useInvalidPluginOAuthClientSchema(apiMap.getOauthClientSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSetPluginOAuthCustomClientHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useSetPluginOAuthCustomClient(apiMap.setCustomOauthClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeletePluginOAuthCustomClientHook = (pluginPayload: PluginPayload) => {
|
||||||
|
const apiMap = useGetApi(pluginPayload)
|
||||||
|
|
||||||
|
return useDeletePluginOAuthCustomClient(apiMap.deleteCustomOAuthClient)
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
AuthCategory,
|
||||||
|
} from '../types'
|
||||||
|
import type {
|
||||||
|
CredentialTypeEnum,
|
||||||
|
PluginPayload,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export const useGetApi = ({ category = AuthCategory.tool, provider }: PluginPayload) => {
|
||||||
|
if (category === AuthCategory.tool) {
|
||||||
|
return {
|
||||||
|
getCredentialInfo: `/workspaces/current/tool-provider/builtin/${provider}/credential/info`,
|
||||||
|
setDefaultCredential: `/workspaces/current/tool-provider/builtin/${provider}/default-credential`,
|
||||||
|
getCredentials: `/workspaces/current/tool-provider/builtin/${provider}/credentials`,
|
||||||
|
addCredential: `/workspaces/current/tool-provider/builtin/${provider}/add`,
|
||||||
|
updateCredential: `/workspaces/current/tool-provider/builtin/${provider}/update`,
|
||||||
|
deleteCredential: `/workspaces/current/tool-provider/builtin/${provider}/delete`,
|
||||||
|
getCredentialSchema: (credential_type: CredentialTypeEnum) => `/workspaces/current/tool-provider/builtin/${provider}/credential/schema/${credential_type}`,
|
||||||
|
getOauthUrl: `/oauth/plugin/${provider}/tool/authorization-url`,
|
||||||
|
getOauthClientSchema: `/workspaces/current/tool-provider/builtin/${provider}/oauth/client-schema`,
|
||||||
|
setCustomOauthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
|
||||||
|
getCustomOAuthClientValues: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
|
||||||
|
deleteCustomOAuthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCredentialInfo: '',
|
||||||
|
setDefaultCredential: '',
|
||||||
|
getCredentials: '',
|
||||||
|
addCredential: '',
|
||||||
|
updateCredential: '',
|
||||||
|
deleteCredential: '',
|
||||||
|
getCredentialSchema: () => '',
|
||||||
|
getOauthUrl: '',
|
||||||
|
getOauthClientSchema: '',
|
||||||
|
setCustomOauthClient: '',
|
||||||
|
getCustomOAuthClientValues: '',
|
||||||
|
deleteCustomOAuthClient: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import {
|
||||||
|
useGetPluginCredentialInfoHook,
|
||||||
|
useInvalidPluginCredentialInfoHook,
|
||||||
|
} from './use-credential'
|
||||||
|
import { CredentialTypeEnum } from '../types'
|
||||||
|
import type { PluginPayload } from '../types'
|
||||||
|
|
||||||
|
export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) => {
|
||||||
|
const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable)
|
||||||
|
const { isCurrentWorkspaceManager } = useAppContext()
|
||||||
|
const isAuthorized = !!data?.credentials.length
|
||||||
|
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)
|
||||||
|
const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY)
|
||||||
|
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthorized,
|
||||||
|
canOAuth,
|
||||||
|
canApiKey,
|
||||||
|
credentials: data?.credentials || [],
|
||||||
|
disabled: !isCurrentWorkspaceManager,
|
||||||
|
invalidPluginCredentialInfo,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
export { default as PluginAuth } from './plugin-auth'
|
||||||
|
export { default as Authorized } from './authorized'
|
||||||
|
export { default as AuthorizedInNode } from './authorized-in-node'
|
||||||
|
export { default as PluginAuthInAgent } from './plugin-auth-in-agent'
|
||||||
|
export { usePluginAuth } from './hooks/use-plugin-auth'
|
||||||
|
export * from './types'
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { RiArrowDownSLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Authorize from './authorize'
|
||||||
|
import Authorized from './authorized'
|
||||||
|
import type {
|
||||||
|
Credential,
|
||||||
|
PluginPayload,
|
||||||
|
} from './types'
|
||||||
|
import { usePluginAuth } from './hooks/use-plugin-auth'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import Indicator from '@/app/components/header/indicator'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
|
||||||
|
type PluginAuthInAgentProps = {
|
||||||
|
pluginPayload: PluginPayload
|
||||||
|
credentialId?: string
|
||||||
|
onAuthorizationItemClick?: (id: string) => void
|
||||||
|
}
|
||||||
|
const PluginAuthInAgent = ({
|
||||||
|
pluginPayload,
|
||||||
|
credentialId,
|
||||||
|
onAuthorizationItemClick,
|
||||||
|
}: PluginAuthInAgentProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const {
|
||||||
|
isAuthorized,
|
||||||
|
canOAuth,
|
||||||
|
canApiKey,
|
||||||
|
credentials,
|
||||||
|
disabled,
|
||||||
|
invalidPluginCredentialInfo,
|
||||||
|
} = usePluginAuth(pluginPayload, true)
|
||||||
|
|
||||||
|
const extraAuthorizationItems: Credential[] = [
|
||||||
|
{
|
||||||
|
id: '__workspace_default__',
|
||||||
|
name: t('plugin.auth.workspaceDefault'),
|
||||||
|
provider: '',
|
||||||
|
is_default: !credentialId,
|
||||||
|
isWorkspaceDefault: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleAuthorizationItemClick = useCallback((id: string) => {
|
||||||
|
onAuthorizationItemClick?.(id)
|
||||||
|
setIsOpen(false)
|
||||||
|
}, [
|
||||||
|
onAuthorizationItemClick,
|
||||||
|
setIsOpen,
|
||||||
|
])
|
||||||
|
|
||||||
|
const renderTrigger = useCallback((isOpen?: boolean) => {
|
||||||
|
let label = ''
|
||||||
|
let removed = false
|
||||||
|
if (!credentialId) {
|
||||||
|
label = t('plugin.auth.workspaceDefault')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const credential = credentials.find(c => c.id === credentialId)
|
||||||
|
label = credential ? credential.name : t('plugin.auth.authRemoved')
|
||||||
|
removed = !credential
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'w-full',
|
||||||
|
isOpen && 'bg-components-button-secondary-bg-hover',
|
||||||
|
removed && 'text-text-destructive',
|
||||||
|
)}>
|
||||||
|
<Indicator
|
||||||
|
className='mr-2'
|
||||||
|
color={removed ? 'red' : 'green'}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
<RiArrowDownSLine className='ml-0.5 h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}, [credentialId, credentials, t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
!isAuthorized && (
|
||||||
|
<Authorize
|
||||||
|
pluginPayload={pluginPayload}
|
||||||
|
canOAuth={canOAuth}
|
||||||
|
canApiKey={canApiKey}
|
||||||
|
disabled={disabled}
|
||||||
|
onUpdate={invalidPluginCredentialInfo}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
isAuthorized && (
|
||||||
|
<Authorized
|
||||||
|
pluginPayload={pluginPayload}
|
||||||
|
credentials={credentials}
|
||||||
|
canOAuth={canOAuth}
|
||||||
|
canApiKey={canApiKey}
|
||||||
|
disabled={disabled}
|
||||||
|
disableSetDefault
|
||||||
|
onItemClick={handleAuthorizationItemClick}
|
||||||
|
extraAuthorizationItems={extraAuthorizationItems}
|
||||||
|
showItemSelectedIcon
|
||||||
|
renderTrigger={renderTrigger}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
selectedCredentialId={credentialId || '__workspace_default__'}
|
||||||
|
onUpdate={invalidPluginCredentialInfo}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(PluginAuthInAgent)
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import Authorize from './authorize'
|
||||||
|
import Authorized from './authorized'
|
||||||
|
import type { PluginPayload } from './types'
|
||||||
|
import { usePluginAuth } from './hooks/use-plugin-auth'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
|
||||||
|
type PluginAuthProps = {
|
||||||
|
pluginPayload: PluginPayload
|
||||||
|
children?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
const PluginAuth = ({
|
||||||
|
pluginPayload,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: PluginAuthProps) => {
|
||||||
|
const {
|
||||||
|
isAuthorized,
|
||||||
|
canOAuth,
|
||||||
|
canApiKey,
|
||||||
|
credentials,
|
||||||
|
disabled,
|
||||||
|
invalidPluginCredentialInfo,
|
||||||
|
} = usePluginAuth(pluginPayload, !!pluginPayload.provider)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(!isAuthorized && className)}>
|
||||||
|
{
|
||||||
|
!isAuthorized && (
|
||||||
|
<Authorize
|
||||||
|
pluginPayload={pluginPayload}
|
||||||
|
canOAuth={canOAuth}
|
||||||
|
canApiKey={canApiKey}
|
||||||
|
disabled={disabled}
|
||||||
|
onUpdate={invalidPluginCredentialInfo}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
isAuthorized && !children && (
|
||||||
|
<Authorized
|
||||||
|
pluginPayload={pluginPayload}
|
||||||
|
credentials={credentials}
|
||||||
|
canOAuth={canOAuth}
|
||||||
|
canApiKey={canApiKey}
|
||||||
|
disabled={disabled}
|
||||||
|
onUpdate={invalidPluginCredentialInfo}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
isAuthorized && children
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(PluginAuth)
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
export enum AuthCategory {
|
||||||
|
tool = 'tool',
|
||||||
|
datasource = 'datasource',
|
||||||
|
model = 'model',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginPayload = {
|
||||||
|
category: AuthCategory
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CredentialTypeEnum {
|
||||||
|
OAUTH2 = 'oauth2',
|
||||||
|
API_KEY = 'api-key',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Credential = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
credential_type?: CredentialTypeEnum
|
||||||
|
is_default: boolean
|
||||||
|
credentials?: Record<string, any>
|
||||||
|
isWorkspaceDefault?: boolean
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record<string, any>) => {
|
||||||
|
const transformedValues: Record<string, any> = { ...values }
|
||||||
|
|
||||||
|
isPristineSecretInputNames.forEach((name) => {
|
||||||
|
if (transformedValues[name])
|
||||||
|
transformedValues[name] = '[__HIDDEN__]'
|
||||||
|
})
|
||||||
|
|
||||||
|
return transformedValues
|
||||||
|
}
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
import { del, get, post } from './base'
|
||||||
|
import { useInvalid } from './use-base'
|
||||||
|
import type {
|
||||||
|
Credential,
|
||||||
|
CredentialTypeEnum,
|
||||||
|
} from '@/app/components/plugins/plugin-auth/types'
|
||||||
|
import type { FormSchema } from '@/app/components/base/form/types'
|
||||||
|
|
||||||
|
const NAME_SPACE = 'plugins-auth'
|
||||||
|
|
||||||
|
export const useGetPluginCredentialInfo = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
enabled: !!url,
|
||||||
|
queryKey: [NAME_SPACE, 'credential-info', url],
|
||||||
|
queryFn: () => get<{
|
||||||
|
supported_credential_types: string[]
|
||||||
|
credentials: Credential[]
|
||||||
|
is_oauth_custom_client_enabled: boolean
|
||||||
|
}>(url),
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInvalidPluginCredentialInfo = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useInvalid([NAME_SPACE, 'credential-info', url])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSetPluginDefaultCredential = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => {
|
||||||
|
return post(url, { body: { id } })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetPluginCredentialList = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [NAME_SPACE, 'credential-list', url],
|
||||||
|
queryFn: () => get(url),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAddPluginCredential = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
credentials: Record<string, any>
|
||||||
|
type: CredentialTypeEnum
|
||||||
|
name?: string
|
||||||
|
}) => {
|
||||||
|
return post(url, { body: params })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdatePluginCredential = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
credential_id: string
|
||||||
|
credentials?: Record<string, any>
|
||||||
|
name?: string
|
||||||
|
}) => {
|
||||||
|
return post(url, { body: params })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeletePluginCredential = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: { credential_id: string }) => {
|
||||||
|
return post(url, { body: params })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetPluginCredentialSchema = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [NAME_SPACE, 'credential-schema', url],
|
||||||
|
queryFn: () => get<FormSchema[]>(url),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetPluginOAuthUrl = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationKey: [NAME_SPACE, 'oauth-url', url],
|
||||||
|
mutationFn: () => {
|
||||||
|
return get<
|
||||||
|
{
|
||||||
|
authorization_url: string
|
||||||
|
state: string
|
||||||
|
context_id: string
|
||||||
|
}>(url)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetPluginOAuthClientSchema = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [NAME_SPACE, 'oauth-client-schema', url],
|
||||||
|
queryFn: () => get<{
|
||||||
|
schema: FormSchema[]
|
||||||
|
is_oauth_custom_client_enabled: boolean
|
||||||
|
is_system_oauth_params_exists?: boolean
|
||||||
|
client_params?: Record<string, any>
|
||||||
|
redirect_uri?: string
|
||||||
|
}>(url),
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInvalidPluginOAuthClientSchema = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useInvalid([NAME_SPACE, 'oauth-client-schema', url])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSetPluginOAuthCustomClient = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: {
|
||||||
|
client_params: Record<string, any>
|
||||||
|
enable_oauth_custom_client: boolean
|
||||||
|
}) => {
|
||||||
|
return post<{ result: string }>(url, { body: params })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeletePluginOAuthCustomClient = (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
return del<{ result: string }>(url)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue