Merge remote-tracking branch 'origin/main' into feat/oauth

pull/22550/head
Harry 11 months ago
commit ff03ae1a57

@ -16,6 +16,8 @@ body:
required: true
- label: I confirm that I am using English to submit this report, otherwise it will be closed.
required: true
- label: 【中文用户 & Non English User】请使用英语提交否则会被关闭
required: true
- label: "Please do not modify this template :) and fill in all the required fields."
required: true

@ -495,6 +495,8 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
# Reset password token expiry minutes
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
CREATE_TIDB_SERVICE_JOB_ENABLED=false
@ -505,6 +507,8 @@ LOGIN_LOCKOUT_DURATION=86400
# Enable OpenTelemetry
ENABLE_OTEL=false
OTLP_TRACE_ENDPOINT=
OTLP_METRIC_ENDPOINT=
OTLP_BASE_ENDPOINT=http://localhost:4318
OTLP_API_KEY=
OTEL_EXPORTER_OTLP_PROTOCOL=

@ -31,6 +31,15 @@ class SecurityConfig(BaseSettings):
description="Duration in minutes for which a password reset token remains valid",
default=5,
)
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
description="Duration in minutes for which a change email token remains valid",
default=5,
)
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
description="Duration in minutes for which a owner transfer token remains valid",
default=5,
)
LOGIN_DISABLED: bool = Field(
description="Whether to disable login checks",
@ -614,6 +623,16 @@ class AuthConfig(BaseSettings):
default=86400,
)
CHANGE_EMAIL_LOCKOUT_DURATION: PositiveInt = Field(
description="Time (in seconds) a user must wait before retrying change email after exceeding the rate limit.",
default=86400,
)
OWNER_TRANSFER_LOCKOUT_DURATION: PositiveInt = Field(
description="Time (in seconds) a user must wait before retrying owner transfer after exceeding the rate limit.",
default=86400,
)
class ModerationConfig(BaseSettings):
"""

@ -12,6 +12,16 @@ class OTelConfig(BaseSettings):
default=False,
)
OTLP_TRACE_ENDPOINT: str = Field(
description="OTLP trace endpoint",
default="",
)
OTLP_METRIC_ENDPOINT: str = Field(
description="OTLP metric endpoint",
default="",
)
OTLP_BASE_ENDPOINT: str = Field(
description="OTLP base endpoint",
default="http://localhost:4318",

@ -31,6 +31,18 @@ class PasswordResetRateLimitExceededError(BaseHTTPException):
code = 429
class EmailChangeRateLimitExceededError(BaseHTTPException):
error_code = "email_change_rate_limit_exceeded"
description = "Too many email change emails have been sent. Please try again in 1 minutes."
code = 429
class OwnerTransferRateLimitExceededError(BaseHTTPException):
error_code = "owner_transfer_rate_limit_exceeded"
description = "Too many owner tansfer emails have been sent. Please try again in 1 minutes."
code = 429
class EmailCodeError(BaseHTTPException):
error_code = "email_code_error"
description = "Email code is invalid or expired."
@ -65,3 +77,39 @@ class EmailPasswordResetLimitError(BaseHTTPException):
error_code = "email_password_reset_limit"
description = "Too many failed password reset attempts. Please try again in 24 hours."
code = 429
class EmailChangeLimitError(BaseHTTPException):
error_code = "email_change_limit"
description = "Too many failed email change attempts. Please try again in 24 hours."
code = 429
class EmailAlreadyInUseError(BaseHTTPException):
error_code = "email_already_in_use"
description = "A user with this email already exists."
code = 400
class OwnerTransferLimitError(BaseHTTPException):
error_code = "owner_transfer_limit"
description = "Too many failed owner transfer attempts. Please try again in 24 hours."
code = 429
class NotOwnerError(BaseHTTPException):
error_code = "not_owner"
description = "You are not the owner of the workspace."
code = 400
class CannotTransferOwnerToSelfError(BaseHTTPException):
error_code = "cannot_transfer_owner_to_self"
description = "You cannot transfer ownership to yourself."
code = 400
class MemberNotInTenantError(BaseHTTPException):
error_code = "member_not_in_tenant"
description = "The member is not in the workspace."
code = 400

@ -25,12 +25,6 @@ class UnsupportedFileTypeError(BaseHTTPException):
code = 415
class HighQualityDatasetOnlyError(BaseHTTPException):
error_code = "high_quality_dataset_only"
description = "Current operation only supports 'high-quality' datasets."
code = 400
class DatasetNotInitializedError(BaseHTTPException):
error_code = "dataset_not_initialized"
description = "The dataset is still being initialized or indexing. Please wait a moment."

@ -4,10 +4,20 @@ import pytz
from flask import request
from flask_login import current_user
from flask_restful import Resource, fields, marshal_with, reqparse
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from constants.languages import supported_language
from controllers.console import api
from controllers.console.auth.error import (
EmailAlreadyInUseError,
EmailChangeLimitError,
EmailCodeError,
InvalidEmailError,
InvalidTokenError,
)
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
CurrentPasswordIncorrectError,
@ -18,15 +28,17 @@ from controllers.console.workspace.error import (
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_enabled,
enable_change_email,
enterprise_license_required,
only_edition_cloud,
setup_required,
)
from extensions.ext_database import db
from fields.member_fields import account_fields
from libs.helper import TimestampField, timezone
from libs.helper import TimestampField, email, extract_remote_ip, timezone
from libs.login import login_required
from models import AccountIntegrate, InvitationCode
from models.account import Account
from services.account_service import AccountService
from services.billing_service import BillingService
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
@ -369,6 +381,134 @@ class EducationAutoCompleteApi(Resource):
return BillingService.EducationIdentity.autocomplete(args["keywords"], args["page"], args["limit"])
class ChangeEmailSendEmailApi(Resource):
@enable_change_email
@setup_required
@login_required
@account_initialization_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
parser.add_argument("language", type=str, required=False, location="json")
parser.add_argument("phase", type=str, required=False, location="json")
parser.add_argument("token", type=str, required=False, location="json")
args = parser.parse_args()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
if args["language"] is not None and args["language"] == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
account = None
user_email = args["email"]
if args["phase"] is not None and args["phase"] == "new_email":
if args["token"] is None:
raise InvalidTokenError()
reset_data = AccountService.get_change_email_data(args["token"])
if reset_data is None:
raise InvalidTokenError()
user_email = reset_data.get("email", "")
if user_email != current_user.email:
raise InvalidEmailError()
else:
with Session(db.engine) as session:
account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none()
if account is None:
raise AccountNotFound()
token = AccountService.send_change_email_email(
account=account, email=args["email"], old_email=user_email, language=language, phase=args["phase"]
)
return {"result": "success", "data": token}
class ChangeEmailCheckApi(Resource):
@enable_change_email
@setup_required
@login_required
@account_initialization_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
parser.add_argument("code", type=str, required=True, location="json")
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
args = parser.parse_args()
user_email = args["email"]
is_change_email_error_rate_limit = AccountService.is_change_email_error_rate_limit(args["email"])
if is_change_email_error_rate_limit:
raise EmailChangeLimitError()
token_data = AccountService.get_change_email_data(args["token"])
if token_data is None:
raise InvalidTokenError()
if user_email != token_data.get("email"):
raise InvalidEmailError()
if args["code"] != token_data.get("code"):
AccountService.add_change_email_error_rate_limit(args["email"])
raise EmailCodeError()
# Verified, revoke the first token
AccountService.revoke_change_email_token(args["token"])
# Refresh token data by generating a new token
_, new_token = AccountService.generate_change_email_token(
user_email, code=args["code"], old_email=token_data.get("old_email"), additional_data={}
)
AccountService.reset_change_email_error_rate_limit(args["email"])
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
class ChangeEmailResetApi(Resource):
@enable_change_email
@setup_required
@login_required
@account_initialization_required
@marshal_with(account_fields)
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("new_email", type=email, required=True, location="json")
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
args = parser.parse_args()
reset_data = AccountService.get_change_email_data(args["token"])
if not reset_data:
raise InvalidTokenError()
AccountService.revoke_change_email_token(args["token"])
if not AccountService.check_email_unique(args["new_email"]):
raise EmailAlreadyInUseError()
old_email = reset_data.get("old_email", "")
if current_user.email != old_email:
raise AccountNotFound()
updated_account = AccountService.update_account(current_user, email=args["new_email"])
return updated_account
class CheckEmailUnique(Resource):
@setup_required
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("email", type=email, required=True, location="json")
args = parser.parse_args()
if not AccountService.check_email_unique(args["email"]):
raise EmailAlreadyInUseError()
return {"result": "success"}
# Register API resources
api.add_resource(AccountInitApi, "/account/init")
api.add_resource(AccountProfileApi, "/account/profile")
@ -385,5 +525,10 @@ api.add_resource(AccountDeleteUpdateFeedbackApi, "/account/delete/feedback")
api.add_resource(EducationVerifyApi, "/account/education/verify")
api.add_resource(EducationApi, "/account/education")
api.add_resource(EducationAutoCompleteApi, "/account/education/autocomplete")
# Change email
api.add_resource(ChangeEmailSendEmailApi, "/account/change-email")
api.add_resource(ChangeEmailCheckApi, "/account/change-email/validity")
api.add_resource(ChangeEmailResetApi, "/account/change-email/reset")
api.add_resource(CheckEmailUnique, "/account/change-email/check-email-unique")
# api.add_resource(AccountEmailApi, '/account/email')
# api.add_resource(AccountEmailVerifyApi, '/account/email-verify')

@ -13,12 +13,6 @@ class CurrentPasswordIncorrectError(BaseHTTPException):
code = 400
class ProviderRequestFailedError(BaseHTTPException):
error_code = "provider_request_failed"
description = None
code = 400
class InvalidInvitationCodeError(BaseHTTPException):
error_code = "invalid_invitation_code"
description = "Invalid invitation code."

@ -1,22 +1,34 @@
from urllib import parse
from flask import request
from flask_login import current_user
from flask_restful import Resource, abort, marshal_with, reqparse
import services
from configs import dify_config
from controllers.console import api
from controllers.console.error import WorkspaceMembersLimitExceeded
from controllers.console.auth.error import (
CannotTransferOwnerToSelfError,
EmailCodeError,
InvalidEmailError,
InvalidTokenError,
MemberNotInTenantError,
NotOwnerError,
OwnerTransferLimitError,
)
from controllers.console.error import EmailSendIpLimitError, WorkspaceMembersLimitExceeded
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
is_allow_transfer_owner,
setup_required,
)
from extensions.ext_database import db
from fields.member_fields import account_with_role_list_fields
from libs.helper import extract_remote_ip
from libs.login import login_required
from models.account import Account, TenantAccountRole
from services.account_service import RegisterService, TenantService
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import AccountAlreadyInTenantError
from services.feature_service import FeatureService
@ -156,8 +168,148 @@ class DatasetOperatorMemberListApi(Resource):
return {"result": "success", "accounts": members}, 200
class SendOwnerTransferEmailApi(Resource):
"""Send owner transfer email."""
@setup_required
@login_required
@account_initialization_required
@is_allow_transfer_owner
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("language", type=str, required=False, location="json")
args = parser.parse_args()
ip_address = extract_remote_ip(request)
if AccountService.is_email_send_ip_limit(ip_address):
raise EmailSendIpLimitError()
# check if the current user is the owner of the workspace
if not TenantService.is_owner(current_user, current_user.current_tenant):
raise NotOwnerError()
if args["language"] is not None and args["language"] == "zh-Hans":
language = "zh-Hans"
else:
language = "en-US"
email = current_user.email
token = AccountService.send_owner_transfer_email(
account=current_user,
email=email,
language=language,
workspace_name=current_user.current_tenant.name,
)
return {"result": "success", "data": token}
class OwnerTransferCheckApi(Resource):
@setup_required
@login_required
@account_initialization_required
@is_allow_transfer_owner
def post(self):
parser = reqparse.RequestParser()
parser.add_argument("code", type=str, required=True, location="json")
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
args = parser.parse_args()
# check if the current user is the owner of the workspace
if not TenantService.is_owner(current_user, current_user.current_tenant):
raise NotOwnerError()
user_email = current_user.email
is_owner_transfer_error_rate_limit = AccountService.is_owner_transfer_error_rate_limit(user_email)
if is_owner_transfer_error_rate_limit:
raise OwnerTransferLimitError()
token_data = AccountService.get_owner_transfer_data(args["token"])
if token_data is None:
raise InvalidTokenError()
if user_email != token_data.get("email"):
raise InvalidEmailError()
if args["code"] != token_data.get("code"):
AccountService.add_owner_transfer_error_rate_limit(user_email)
raise EmailCodeError()
# Verified, revoke the first token
AccountService.revoke_owner_transfer_token(args["token"])
# Refresh token data by generating a new token
_, new_token = AccountService.generate_owner_transfer_token(user_email, code=args["code"], additional_data={})
AccountService.reset_owner_transfer_error_rate_limit(user_email)
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
class OwnerTransfer(Resource):
@setup_required
@login_required
@account_initialization_required
@is_allow_transfer_owner
def post(self, member_id):
parser = reqparse.RequestParser()
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
args = parser.parse_args()
# check if the current user is the owner of the workspace
if not TenantService.is_owner(current_user, current_user.current_tenant):
raise NotOwnerError()
if current_user.id == str(member_id):
raise CannotTransferOwnerToSelfError()
transfer_token_data = AccountService.get_owner_transfer_data(args["token"])
if not transfer_token_data:
print(transfer_token_data, "transfer_token_data")
raise InvalidTokenError()
if transfer_token_data.get("email") != current_user.email:
print(transfer_token_data.get("email"), current_user.email)
raise InvalidEmailError()
AccountService.revoke_owner_transfer_token(args["token"])
member = db.session.get(Account, str(member_id))
if not member:
abort(404)
else:
member_account = member
if not TenantService.is_member(member_account, current_user.current_tenant):
raise MemberNotInTenantError()
try:
assert member is not None, "Member not found"
TenantService.update_member_role(current_user.current_tenant, member, "owner", current_user)
AccountService.send_new_owner_transfer_notify_email(
account=member,
email=member.email,
workspace_name=current_user.current_tenant.name,
)
AccountService.send_old_owner_transfer_notify_email(
account=current_user,
email=current_user.email,
workspace_name=current_user.current_tenant.name,
new_owner_email=member.email,
)
except Exception as e:
raise ValueError(str(e))
return {"result": "success"}
api.add_resource(MemberListApi, "/workspaces/current/members")
api.add_resource(MemberInviteEmailApi, "/workspaces/current/members/invite-email")
api.add_resource(MemberCancelInviteApi, "/workspaces/current/members/<uuid:member_id>")
api.add_resource(MemberUpdateRoleApi, "/workspaces/current/members/<uuid:member_id>/update-role")
api.add_resource(DatasetOperatorMemberListApi, "/workspaces/current/dataset-operators")
# owner transfer
api.add_resource(SendOwnerTransferEmailApi, "/workspaces/current/members/send-owner-transfer-confirm-email")
api.add_resource(OwnerTransferCheckApi, "/workspaces/current/members/owner-transfer-check")
api.add_resource(OwnerTransfer, "/workspaces/current/members/<uuid:member_id>/owner-transfer")

@ -235,3 +235,29 @@ def email_password_login_enabled(view):
abort(403)
return decorated
def enable_change_email(view):
@wraps(view)
def decorated(*args, **kwargs):
features = FeatureService.get_system_features()
if features.enable_change_email:
return view(*args, **kwargs)
# otherwise, return 403
abort(403)
return decorated
def is_allow_transfer_owner(view):
@wraps(view)
def decorated(*args, **kwargs):
features = FeatureService.get_features(current_user.current_tenant_id)
if features.is_allow_transfer_workspace:
return view(*args, **kwargs)
# otherwise, return 403
abort(403)
return decorated

@ -25,12 +25,6 @@ class UnsupportedFileTypeError(BaseHTTPException):
code = 415
class HighQualityDatasetOnlyError(BaseHTTPException):
error_code = "high_quality_dataset_only"
description = "Current operation only supports 'high-quality' datasets."
code = 400
class DatasetNotInitializedError(BaseHTTPException):
error_code = "dataset_not_initialized"
description = "The dataset is still being initialized or indexing. Please wait a moment."

@ -38,69 +38,6 @@ _logger = logging.getLogger(__name__)
class AppRunner:
def get_pre_calculate_rest_tokens(
self,
app_record: App,
model_config: ModelConfigWithCredentialsEntity,
prompt_template_entity: PromptTemplateEntity,
inputs: Mapping[str, str],
files: Sequence["File"],
query: Optional[str] = None,
) -> int:
"""
Get pre calculate rest tokens
:param app_record: app record
:param model_config: model config entity
:param prompt_template_entity: prompt template entity
:param inputs: inputs
:param files: files
:param query: query
:return:
"""
# Invoke model
model_instance = ModelInstance(
provider_model_bundle=model_config.provider_model_bundle, model=model_config.model
)
model_context_tokens = model_config.model_schema.model_properties.get(ModelPropertyKey.CONTEXT_SIZE)
max_tokens = 0
for parameter_rule in model_config.model_schema.parameter_rules:
if parameter_rule.name == "max_tokens" or (
parameter_rule.use_template and parameter_rule.use_template == "max_tokens"
):
max_tokens = (
model_config.parameters.get(parameter_rule.name)
or model_config.parameters.get(parameter_rule.use_template or "")
) or 0
if model_context_tokens is None:
return -1
if max_tokens is None:
max_tokens = 0
# get prompt messages without memory and context
prompt_messages, stop = self.organize_prompt_messages(
app_record=app_record,
model_config=model_config,
prompt_template_entity=prompt_template_entity,
inputs=inputs,
files=files,
query=query,
)
prompt_tokens = model_instance.get_llm_num_tokens(prompt_messages)
rest_tokens: int = model_context_tokens - max_tokens - prompt_tokens
if rest_tokens < 0:
raise InvokeBadRequestError(
"Query or prefix prompt is too long, you can reduce the prefix prompt, "
"or shrink the max token, or switch to a llm with a larger token limit size."
)
return rest_tokens
def recalc_llm_max_tokens(
self, model_config: ModelConfigWithCredentialsEntity, prompt_messages: list[PromptMessage]
):

@ -10,8 +10,3 @@ class RecordNotFoundError(TaskPipilineError):
class WorkflowRunNotFoundError(RecordNotFoundError):
def __init__(self, workflow_run_id: str):
super().__init__("WorkflowRun", workflow_run_id)
class WorkflowNodeExecutionNotFoundError(RecordNotFoundError):
def __init__(self, workflow_node_execution_id: str):
super().__init__("WorkflowNodeExecution", workflow_node_execution_id)

@ -7,13 +7,6 @@ if TYPE_CHECKING:
_tool_file_manager_factory: Callable[[], "ToolFileManager"] | None = None
class ToolFileParser:
@staticmethod
def get_tool_file_manager() -> "ToolFileManager":
assert _tool_file_manager_factory is not None
return _tool_file_manager_factory()
def set_tool_file_manager_factory(factory: Callable[[], "ToolFileManager"]) -> None:
global _tool_file_manager_factory
_tool_file_manager_factory = factory

@ -1,52 +0,0 @@
import base64
import hashlib
import hmac
import os
import time
from pydantic import BaseModel, Field
from configs import dify_config
class SignedUrlParams(BaseModel):
sign_key: str = Field(..., description="The sign key")
timestamp: str = Field(..., description="Timestamp")
nonce: str = Field(..., description="Nonce")
sign: str = Field(..., description="Signature")
class UrlSigner:
@classmethod
def get_signed_url(cls, url: str, sign_key: str, prefix: str) -> str:
signed_url_params = cls.get_signed_url_params(sign_key, prefix)
return (
f"{url}?timestamp={signed_url_params.timestamp}"
f"&nonce={signed_url_params.nonce}&sign={signed_url_params.sign}"
)
@classmethod
def get_signed_url_params(cls, sign_key: str, prefix: str) -> SignedUrlParams:
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
sign = cls._sign(sign_key, timestamp, nonce, prefix)
return SignedUrlParams(sign_key=sign_key, timestamp=timestamp, nonce=nonce, sign=sign)
@classmethod
def verify(cls, sign_key: str, timestamp: str, nonce: str, sign: str, prefix: str) -> bool:
recalculated_sign = cls._sign(sign_key, timestamp, nonce, prefix)
return sign == recalculated_sign
@classmethod
def _sign(cls, sign_key: str, timestamp: str, nonce: str, prefix: str) -> str:
if not dify_config.SECRET_KEY:
raise Exception("SECRET_KEY is not set")
data_to_sign = f"{prefix}|{sign_key}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode()
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
return encoded_sign

@ -148,9 +148,7 @@ class MCPServerStreamableHTTPRequestHandler:
if not self.end_user:
raise ValueError("User not found")
request = cast(types.CallToolRequest, self.request.root)
args = request.params.arguments
if not args:
raise ValueError("No arguments provided")
args = request.params.arguments or {}
if self.app.mode in {AppMode.WORKFLOW.value}:
args = {"inputs": args}
elif self.app.mode in {AppMode.COMPLETION.value}:

@ -135,17 +135,6 @@ class PluginEntity(PluginInstallation):
return self
class GithubPackage(BaseModel):
repo: str
version: str
package: str
class GithubVersion(BaseModel):
repo: str
version: str
class GenericProviderID:
organization: str
plugin_name: str

@ -1,12 +0,0 @@
"""Abstract interface for document clean implementations."""
from core.rag.cleaner.cleaner_base import BaseCleaner
class UnstructuredNonAsciiCharsCleaner(BaseCleaner):
def clean(self, content) -> str:
"""clean document content."""
from unstructured.cleaners.core import clean_extra_whitespace
# Returns "ITEM 1A: RISK FACTORS"
return clean_extra_whitespace(content)

@ -1,15 +0,0 @@
"""Abstract interface for document clean implementations."""
from core.rag.cleaner.cleaner_base import BaseCleaner
class UnstructuredGroupBrokenParagraphsCleaner(BaseCleaner):
def clean(self, content) -> str:
"""clean document content."""
import re
from unstructured.cleaners.core import group_broken_paragraphs
para_split_re = re.compile(r"(\s*\n\s*){3}")
return group_broken_paragraphs(content, paragraph_split=para_split_re)

@ -1,12 +0,0 @@
"""Abstract interface for document clean implementations."""
from core.rag.cleaner.cleaner_base import BaseCleaner
class UnstructuredNonAsciiCharsCleaner(BaseCleaner):
def clean(self, content) -> str:
"""clean document content."""
from unstructured.cleaners.core import clean_non_ascii_chars
# Returns "This text contains non-ascii characters!"
return clean_non_ascii_chars(content)

@ -1,12 +0,0 @@
"""Abstract interface for document clean implementations."""
from core.rag.cleaner.cleaner_base import BaseCleaner
class UnstructuredNonAsciiCharsCleaner(BaseCleaner):
def clean(self, content) -> str:
"""Replaces unicode quote characters, such as the \x91 character in a string."""
from unstructured.cleaners.core import replace_unicode_quotes
return replace_unicode_quotes(content)

@ -1,11 +0,0 @@
"""Abstract interface for document clean implementations."""
from core.rag.cleaner.cleaner_base import BaseCleaner
class UnstructuredTranslateTextCleaner(BaseCleaner):
def clean(self, content) -> str:
"""clean document content."""
from unstructured.cleaners.translate import translate_text
return translate_text(content)

@ -1,17 +0,0 @@
from typing import Optional
from pydantic import BaseModel
class ClusterEntity(BaseModel):
"""
Model Config Entity.
"""
name: str
cluster_id: str
displayName: str
region: str
spendingLimit: Optional[int] = 1000
version: str
createdBy: str

@ -9,8 +9,7 @@ from __future__ import annotations
import contextlib
import mimetypes
from abc import ABC, abstractmethod
from collections.abc import Generator, Iterable, Mapping
from collections.abc import Generator, Mapping
from io import BufferedReader, BytesIO
from pathlib import Path, PurePath
from typing import Any, Optional, Union
@ -143,21 +142,3 @@ class Blob(BaseModel):
if self.source:
str_repr += f" {self.source}"
return str_repr
class BlobLoader(ABC):
"""Abstract interface for blob loaders implementation.
Implementer should be able to load raw content from a datasource system according
to some criteria and return the raw content lazily as a stream of blobs.
"""
@abstractmethod
def yield_blobs(
self,
) -> Iterable[Blob]:
"""A lazy loader for raw data represented by Blob object.
Returns:
A generator over blobs
"""

@ -1,47 +0,0 @@
import logging
from core.rag.extractor.extractor_base import BaseExtractor
from core.rag.models.document import Document
logger = logging.getLogger(__name__)
class UnstructuredPDFExtractor(BaseExtractor):
"""Load pdf files.
Args:
file_path: Path to the file to load.
api_url: Unstructured API URL
api_key: Unstructured API Key
"""
def __init__(self, file_path: str, api_url: str, api_key: str):
"""Initialize with file path."""
self._file_path = file_path
self._api_url = api_url
self._api_key = api_key
def extract(self) -> list[Document]:
if self._api_url:
from unstructured.partition.api import partition_via_api
elements = partition_via_api(
filename=self._file_path, api_url=self._api_url, api_key=self._api_key, strategy="auto"
)
else:
from unstructured.partition.pdf import partition_pdf
elements = partition_pdf(filename=self._file_path, strategy="auto")
from unstructured.chunking.title import chunk_by_title
chunks = chunk_by_title(elements, max_characters=2000, combine_text_under_n_chars=2000)
documents = []
for chunk in chunks:
text = chunk.text.strip()
documents.append(Document(page_content=text))
return documents

@ -1,34 +0,0 @@
import logging
from core.rag.extractor.extractor_base import BaseExtractor
from core.rag.models.document import Document
logger = logging.getLogger(__name__)
class UnstructuredTextExtractor(BaseExtractor):
"""Load msg files.
Args:
file_path: Path to the file to load.
"""
def __init__(self, file_path: str, api_url: str):
"""Initialize with file path."""
self._file_path = file_path
self._api_url = api_url
def extract(self) -> list[Document]:
from unstructured.partition.text import partition_text
elements = partition_text(filename=self._file_path)
from unstructured.chunking.title import chunk_by_title
chunks = chunk_by_title(elements, max_characters=2000, combine_text_under_n_chars=2000)
documents = []
for chunk in chunks:
text = chunk.text.strip()
documents.append(Document(page_content=text))
return documents

@ -10,7 +10,6 @@ from typing import (
Any,
Literal,
Optional,
TypedDict,
TypeVar,
Union,
)
@ -168,167 +167,6 @@ class TextSplitter(BaseDocumentTransformer, ABC):
raise NotImplementedError
class CharacterTextSplitter(TextSplitter):
"""Splitting text that looks at characters."""
def __init__(self, separator: str = "\n\n", **kwargs: Any) -> None:
"""Create a new TextSplitter."""
super().__init__(**kwargs)
self._separator = separator
def split_text(self, text: str) -> list[str]:
"""Split incoming text and return chunks."""
# First we naively split the large input into a bunch of smaller ones.
splits = _split_text_with_regex(text, self._separator, self._keep_separator)
_separator = "" if self._keep_separator else self._separator
_good_splits_lengths = [] # cache the lengths of the splits
if splits:
_good_splits_lengths.extend(self._length_function(splits))
return self._merge_splits(splits, _separator, _good_splits_lengths)
class LineType(TypedDict):
"""Line type as typed dict."""
metadata: dict[str, str]
content: str
class HeaderType(TypedDict):
"""Header type as typed dict."""
level: int
name: str
data: str
class MarkdownHeaderTextSplitter:
"""Splitting markdown files based on specified headers."""
def __init__(self, headers_to_split_on: list[tuple[str, str]], return_each_line: bool = False):
"""Create a new MarkdownHeaderTextSplitter.
Args:
headers_to_split_on: Headers we want to track
return_each_line: Return each line w/ associated headers
"""
# Output line-by-line or aggregated into chunks w/ common headers
self.return_each_line = return_each_line
# Given the headers we want to split on,
# (e.g., "#, ##, etc") order by length
self.headers_to_split_on = sorted(headers_to_split_on, key=lambda split: len(split[0]), reverse=True)
def aggregate_lines_to_chunks(self, lines: list[LineType]) -> list[Document]:
"""Combine lines with common metadata into chunks
Args:
lines: Line of text / associated header metadata
"""
aggregated_chunks: list[LineType] = []
for line in lines:
if aggregated_chunks and aggregated_chunks[-1]["metadata"] == line["metadata"]:
# If the last line in the aggregated list
# has the same metadata as the current line,
# append the current content to the last lines's content
aggregated_chunks[-1]["content"] += " \n" + line["content"]
else:
# Otherwise, append the current line to the aggregated list
aggregated_chunks.append(line)
return [Document(page_content=chunk["content"], metadata=chunk["metadata"]) for chunk in aggregated_chunks]
def split_text(self, text: str) -> list[Document]:
"""Split markdown file
Args:
text: Markdown file"""
# Split the input text by newline character ("\n").
lines = text.split("\n")
# Final output
lines_with_metadata: list[LineType] = []
# Content and metadata of the chunk currently being processed
current_content: list[str] = []
current_metadata: dict[str, str] = {}
# Keep track of the nested header structure
# header_stack: List[Dict[str, Union[int, str]]] = []
header_stack: list[HeaderType] = []
initial_metadata: dict[str, str] = {}
for line in lines:
stripped_line = line.strip()
# Check each line against each of the header types (e.g., #, ##)
for sep, name in self.headers_to_split_on:
# Check if line starts with a header that we intend to split on
if stripped_line.startswith(sep) and (
# Header with no text OR header is followed by space
# Both are valid conditions that sep is being used a header
len(stripped_line) == len(sep) or stripped_line[len(sep)] == " "
):
# Ensure we are tracking the header as metadata
if name is not None:
# Get the current header level
current_header_level = sep.count("#")
# Pop out headers of lower or same level from the stack
while header_stack and header_stack[-1]["level"] >= current_header_level:
# We have encountered a new header
# at the same or higher level
popped_header = header_stack.pop()
# Clear the metadata for the
# popped header in initial_metadata
if popped_header["name"] in initial_metadata:
initial_metadata.pop(popped_header["name"])
# Push the current header to the stack
header: HeaderType = {
"level": current_header_level,
"name": name,
"data": stripped_line[len(sep) :].strip(),
}
header_stack.append(header)
# Update initial_metadata with the current header
initial_metadata[name] = header["data"]
# Add the previous line to the lines_with_metadata
# only if current_content is not empty
if current_content:
lines_with_metadata.append(
{
"content": "\n".join(current_content),
"metadata": current_metadata.copy(),
}
)
current_content.clear()
break
else:
if stripped_line:
current_content.append(stripped_line)
elif current_content:
lines_with_metadata.append(
{
"content": "\n".join(current_content),
"metadata": current_metadata.copy(),
}
)
current_content.clear()
current_metadata = initial_metadata.copy()
if current_content:
lines_with_metadata.append({"content": "\n".join(current_content), "metadata": current_metadata})
# lines_with_metadata has each line with associated header metadata
# aggregate these into chunks based on common metadata
if not self.return_each_line:
return self.aggregate_lines_to_chunks(lines_with_metadata)
else:
return [
Document(page_content=chunk["content"], metadata=chunk["metadata"]) for chunk in lines_with_metadata
]
# should be in newer Python versions (3.10+)
# @dataclass(frozen=True, kw_only=True, slots=True)
@dataclass(frozen=True)
class Tokenizer:

@ -91,8 +91,6 @@ class SegmentType(StrEnum):
return SegmentType.OBJECT
elif isinstance(value, File):
return SegmentType.FILE
elif isinstance(value, str):
return SegmentType.STRING
else:
return None

@ -152,7 +152,6 @@ class VariablePool(BaseModel):
self.variable_dictionary[selector[0]] = {}
return
key, hash_key = self._selector_to_keys(selector)
hash_key = hash(tuple(selector[1:]))
self.variable_dictionary[key].pop(hash_key, None)
def convert_template(self, template: str, /):

@ -1,79 +0,0 @@
from typing import Optional
from pydantic import BaseModel
from core.app.entities.app_invoke_entities import InvokeFrom
from core.workflow.nodes.base import BaseIterationState, BaseLoopState, BaseNode
from models.enums import UserFrom
from models.workflow import Workflow, WorkflowType
from .node_entities import NodeRunResult
from .variable_pool import VariablePool
class WorkflowNodeAndResult:
node: BaseNode
result: Optional[NodeRunResult] = None
def __init__(self, node: BaseNode, result: Optional[NodeRunResult] = None):
self.node = node
self.result = result
class WorkflowRunState:
tenant_id: str
app_id: str
workflow_id: str
workflow_type: WorkflowType
user_id: str
user_from: UserFrom
invoke_from: InvokeFrom
workflow_call_depth: int
start_at: float
variable_pool: VariablePool
total_tokens: int = 0
workflow_nodes_and_results: list[WorkflowNodeAndResult]
class NodeRun(BaseModel):
node_id: str
iteration_node_id: str
loop_node_id: str
workflow_node_runs: list[NodeRun]
workflow_node_steps: int
current_iteration_state: Optional[BaseIterationState]
current_loop_state: Optional[BaseLoopState]
def __init__(
self,
workflow: Workflow,
start_at: float,
variable_pool: VariablePool,
user_id: str,
user_from: UserFrom,
invoke_from: InvokeFrom,
workflow_call_depth: int,
):
self.workflow_id = workflow.id
self.tenant_id = workflow.tenant_id
self.app_id = workflow.app_id
self.workflow_type = WorkflowType.value_of(workflow.type)
self.user_id = user_id
self.user_from = user_from
self.invoke_from = invoke_from
self.workflow_call_depth = workflow_call_depth
self.start_at = start_at
self.variable_pool = variable_pool
self.total_tokens = 0
self.workflow_node_steps = 1
self.workflow_node_runs = []
self.current_iteration_state = None
self.current_loop_state = None

@ -1,4 +1,5 @@
from collections.abc import Mapping, Sequence
from decimal import Decimal
from typing import Any, Optional
from configs import dify_config
@ -114,8 +115,10 @@ class CodeNode(BaseNode[CodeNodeData]):
)
if isinstance(value, float):
decimal_value = Decimal(str(value)).normalize()
precision = -decimal_value.as_tuple().exponent if decimal_value.as_tuple().exponent < 0 else 0 # type: ignore[operator]
# raise error if precision is too high
if len(str(value).split(".")[1]) > dify_config.CODE_MAX_PRECISION:
if precision > dify_config.CODE_MAX_PRECISION:
raise OutputValidationError(
f"Output variable `{variable}` has too high precision,"
f" it must be less than {dify_config.CODE_MAX_PRECISION} digits."

@ -1,4 +1,3 @@
import json
from collections.abc import Mapping
from typing import Any
@ -8,18 +7,6 @@ from core.file.models import File
from core.variables import Segment
class WorkflowRuntimeTypeEncoder(json.JSONEncoder):
def default(self, o: Any):
if isinstance(o, Segment):
return o.value
elif isinstance(o, File):
return o.to_dict()
elif isinstance(o, BaseModel):
return o.model_dump(mode="json")
else:
return super().default(o)
class WorkflowRuntimeTypeConverter:
def to_json_encodable(self, value: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
result = self._to_json_encodable_recursive(value)

@ -193,13 +193,22 @@ def init_app(app: DifyApp):
insecure=True,
)
else:
headers = {"Authorization": f"Bearer {dify_config.OTLP_API_KEY}"} if dify_config.OTLP_API_KEY else None
trace_endpoint = dify_config.OTLP_TRACE_ENDPOINT
if not trace_endpoint:
trace_endpoint = dify_config.OTLP_BASE_ENDPOINT + "/v1/traces"
exporter = HTTPSpanExporter(
endpoint=dify_config.OTLP_BASE_ENDPOINT + "/v1/traces",
headers={"Authorization": f"Bearer {dify_config.OTLP_API_KEY}"},
endpoint=trace_endpoint,
headers=headers,
)
metric_endpoint = dify_config.OTLP_METRIC_ENDPOINT
if not metric_endpoint:
metric_endpoint = dify_config.OTLP_BASE_ENDPOINT + "/v1/traces"
metric_exporter = HTTPMetricExporter(
endpoint=dify_config.OTLP_BASE_ENDPOINT + "/v1/metrics",
headers={"Authorization": f"Bearer {dify_config.OTLP_API_KEY}"},
endpoint=metric_endpoint,
headers=headers,
)
else:
exporter = ConsoleSpanExporter()

@ -148,25 +148,6 @@ class StrLen:
return value
class FloatRange:
"""Restrict input to an float in a range (inclusive)"""
def __init__(self, low, high, argument="argument"):
self.low = low
self.high = high
self.argument = argument
def __call__(self, value):
value = _get_float(value)
if value < self.low or value > self.high:
error = "Invalid {arg}: {val}. {arg} must be within the range {lo} - {hi}".format(
arg=self.argument, val=value, lo=self.low, hi=self.high
)
raise ValueError(error)
return value
class DatetimeString:
def __init__(self, format, argument="argument"):
self.format = format

@ -1,11 +0,0 @@
import json
from pydantic import BaseModel
class PydanticModelEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, BaseModel):
return o.model_dump()
else:
super().default(o)

@ -610,14 +610,6 @@ class InstalledApp(Base):
return tenant
class ConversationSource(StrEnum):
"""This enumeration is designed for use with `Conversation.from_source`."""
# NOTE(QuantumGhost): The enumeration members may not cover all possible cases.
API = "api"
CONSOLE = "console"
class Conversation(Base):
__tablename__ = "conversations"
__table_args__ = (

@ -52,8 +52,14 @@ from services.errors.workspace import WorkSpaceNotAllowedCreateError, Workspaces
from services.feature_service import FeatureService
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
from tasks.mail_change_mail_task import send_change_mail_task
from tasks.mail_email_code_login import send_email_code_login_mail_task
from tasks.mail_invite_member_task import send_invite_member_mail_task
from tasks.mail_owner_transfer_task import (
send_new_owner_transfer_notify_email_task,
send_old_owner_transfer_notify_email_task,
send_owner_transfer_confirm_task,
)
from tasks.mail_reset_password_task import send_reset_password_mail_task
@ -75,8 +81,13 @@ class AccountService:
email_code_account_deletion_rate_limiter = RateLimiter(
prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1
)
change_email_rate_limiter = RateLimiter(prefix="change_email_rate_limit", max_attempts=1, time_window=60 * 1)
owner_transfer_rate_limiter = RateLimiter(prefix="owner_transfer_rate_limit", max_attempts=1, time_window=60 * 1)
LOGIN_MAX_ERROR_LIMITS = 5
FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5
CHANGE_EMAIL_MAX_ERROR_LIMITS = 5
OWNER_TRANSFER_MAX_ERROR_LIMITS = 5
@staticmethod
def _get_refresh_token_key(refresh_token: str) -> str:
@ -419,6 +430,101 @@ class AccountService:
cls.reset_password_rate_limiter.increment_rate_limit(account_email)
return token
@classmethod
def send_change_email_email(
cls,
account: Optional[Account] = None,
email: Optional[str] = None,
old_email: Optional[str] = None,
language: Optional[str] = "en-US",
phase: Optional[str] = None,
):
account_email = account.email if account else email
if account_email is None:
raise ValueError("Email must be provided.")
if cls.change_email_rate_limiter.is_rate_limited(account_email):
from controllers.console.auth.error import EmailChangeRateLimitExceededError
raise EmailChangeRateLimitExceededError()
code, token = cls.generate_change_email_token(account_email, account, old_email=old_email)
send_change_mail_task.delay(
language=language,
to=account_email,
code=code,
phase=phase,
)
cls.change_email_rate_limiter.increment_rate_limit(account_email)
return token
@classmethod
def send_owner_transfer_email(
cls,
account: Optional[Account] = None,
email: Optional[str] = None,
language: Optional[str] = "en-US",
workspace_name: Optional[str] = "",
):
account_email = account.email if account else email
if account_email is None:
raise ValueError("Email must be provided.")
if cls.owner_transfer_rate_limiter.is_rate_limited(account_email):
from controllers.console.auth.error import OwnerTransferRateLimitExceededError
raise OwnerTransferRateLimitExceededError()
code, token = cls.generate_owner_transfer_token(account_email, account)
send_owner_transfer_confirm_task.delay(
language=language,
to=account_email,
code=code,
workspace=workspace_name,
)
cls.owner_transfer_rate_limiter.increment_rate_limit(account_email)
return token
@classmethod
def send_old_owner_transfer_notify_email(
cls,
account: Optional[Account] = None,
email: Optional[str] = None,
language: Optional[str] = "en-US",
workspace_name: Optional[str] = "",
new_owner_email: Optional[str] = "",
):
account_email = account.email if account else email
if account_email is None:
raise ValueError("Email must be provided.")
send_old_owner_transfer_notify_email_task.delay(
language=language,
to=account_email,
workspace=workspace_name,
new_owner_email=new_owner_email,
)
@classmethod
def send_new_owner_transfer_notify_email(
cls,
account: Optional[Account] = None,
email: Optional[str] = None,
language: Optional[str] = "en-US",
workspace_name: Optional[str] = "",
):
account_email = account.email if account else email
if account_email is None:
raise ValueError("Email must be provided.")
send_new_owner_transfer_notify_email_task.delay(
language=language,
to=account_email,
workspace=workspace_name,
)
@classmethod
def generate_reset_password_token(
cls,
@ -435,14 +541,64 @@ class AccountService:
)
return code, token
@classmethod
def generate_change_email_token(
cls,
email: str,
account: Optional[Account] = None,
code: Optional[str] = None,
old_email: Optional[str] = None,
additional_data: dict[str, Any] = {},
):
if not code:
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
additional_data["code"] = code
additional_data["old_email"] = old_email
token = TokenManager.generate_token(
account=account, email=email, token_type="change_email", additional_data=additional_data
)
return code, token
@classmethod
def generate_owner_transfer_token(
cls,
email: str,
account: Optional[Account] = None,
code: Optional[str] = None,
additional_data: dict[str, Any] = {},
):
if not code:
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
additional_data["code"] = code
token = TokenManager.generate_token(
account=account, email=email, token_type="owner_transfer", additional_data=additional_data
)
return code, token
@classmethod
def revoke_reset_password_token(cls, token: str):
TokenManager.revoke_token(token, "reset_password")
@classmethod
def revoke_change_email_token(cls, token: str):
TokenManager.revoke_token(token, "change_email")
@classmethod
def revoke_owner_transfer_token(cls, token: str):
TokenManager.revoke_token(token, "owner_transfer")
@classmethod
def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]:
return TokenManager.get_token_data(token, "reset_password")
@classmethod
def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]:
return TokenManager.get_token_data(token, "change_email")
@classmethod
def get_owner_transfer_data(cls, token: str) -> Optional[dict[str, Any]]:
return TokenManager.get_token_data(token, "owner_transfer")
@classmethod
def send_email_code_login_email(
cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US"
@ -552,6 +708,62 @@ class AccountService:
key = f"forgot_password_error_rate_limit:{email}"
redis_client.delete(key)
@staticmethod
@redis_fallback(default_return=None)
def add_change_email_error_rate_limit(email: str) -> None:
key = f"change_email_error_rate_limit:{email}"
count = redis_client.get(key)
if count is None:
count = 0
count = int(count) + 1
redis_client.setex(key, dify_config.CHANGE_EMAIL_LOCKOUT_DURATION, count)
@staticmethod
@redis_fallback(default_return=False)
def is_change_email_error_rate_limit(email: str) -> bool:
key = f"change_email_error_rate_limit:{email}"
count = redis_client.get(key)
if count is None:
return False
count = int(count)
if count > AccountService.CHANGE_EMAIL_MAX_ERROR_LIMITS:
return True
return False
@staticmethod
@redis_fallback(default_return=None)
def reset_change_email_error_rate_limit(email: str):
key = f"change_email_error_rate_limit:{email}"
redis_client.delete(key)
@staticmethod
@redis_fallback(default_return=None)
def add_owner_transfer_error_rate_limit(email: str) -> None:
key = f"owner_transfer_error_rate_limit:{email}"
count = redis_client.get(key)
if count is None:
count = 0
count = int(count) + 1
redis_client.setex(key, dify_config.OWNER_TRANSFER_LOCKOUT_DURATION, count)
@staticmethod
@redis_fallback(default_return=False)
def is_owner_transfer_error_rate_limit(email: str) -> bool:
key = f"owner_transfer_error_rate_limit:{email}"
count = redis_client.get(key)
if count is None:
return False
count = int(count)
if count > AccountService.OWNER_TRANSFER_MAX_ERROR_LIMITS:
return True
return False
@staticmethod
@redis_fallback(default_return=None)
def reset_owner_transfer_error_rate_limit(email: str):
key = f"owner_transfer_error_rate_limit:{email}"
redis_client.delete(key)
@staticmethod
@redis_fallback(default_return=False)
def is_email_send_ip_limit(ip_address: str):
@ -593,6 +805,10 @@ class AccountService:
return False
@staticmethod
def check_email_unique(email: str) -> bool:
return db.session.query(Account).filter_by(email=email).first() is None
class TenantService:
@staticmethod
@ -865,6 +1081,15 @@ class TenantService:
return cast(dict, tenant.custom_config_dict)
@staticmethod
def is_owner(account: Account, tenant: Tenant) -> bool:
return TenantService.get_user_role(account, tenant) == TenantAccountRole.OWNER
@staticmethod
def is_member(account: Account, tenant: Tenant) -> bool:
"""Check if the account is a member of the tenant"""
return TenantService.get_user_role(account, tenant) is not None
class RegisterService:
@classmethod

@ -4,13 +4,6 @@ from typing import Literal, Optional
from pydantic import BaseModel
class SegmentUpdateEntity(BaseModel):
content: str
answer: Optional[str] = None
keywords: Optional[list[str]] = None
enabled: Optional[bool] = None
class ParentMode(StrEnum):
FULL_DOC = "full-doc"
PARAGRAPH = "paragraph"
@ -153,10 +146,6 @@ class MetadataUpdateArgs(BaseModel):
value: Optional[str | int | float] = None
class MetadataValueUpdateArgs(BaseModel):
fields: list[MetadataUpdateArgs]
class MetadataDetail(BaseModel):
id: str
name: str

@ -123,7 +123,7 @@ class FeatureModel(BaseModel):
dataset_operator_enabled: bool = False
webapp_copyright_enabled: bool = False
workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
is_allow_transfer_workspace: bool = True
# pydantic configs
model_config = ConfigDict(protected_namespaces=())
@ -149,6 +149,7 @@ class SystemFeatureModel(BaseModel):
branding: BrandingModel = BrandingModel()
webapp_auth: WebAppAuthModel = WebAppAuthModel()
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
enable_change_email: bool = True
class FeatureService:
@ -186,6 +187,7 @@ class FeatureService:
if dify_config.ENTERPRISE_ENABLED:
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False
cls._fulfill_params_from_enterprise(system_features)
if dify_config.MARKETPLACE_ENABLED:
@ -228,6 +230,8 @@ class FeatureService:
if features.billing.subscription.plan != "sandbox":
features.webapp_copyright_enabled = True
else:
features.is_allow_transfer_workspace = False
if "members" in billing_info:
features.members.size = billing_info["members"]["size"]

@ -0,0 +1,78 @@
import logging
import time
import click
from celery import shared_task # type: ignore
from flask import render_template
from extensions.ext_mail import mail
from services.feature_service import FeatureService
@shared_task(queue="mail")
def send_change_mail_task(language: str, to: str, code: str, phase: str):
"""
Async Send change email mail
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
:param to: Recipient email address
:param code: Change email code
:param phase: Change email phase (new_email, old_email)
"""
if not mail.is_inited():
return
logging.info(click.style("Start change email mail to {}".format(to), fg="green"))
start_at = time.perf_counter()
email_config = {
"zh-Hans": {
"old_email": {
"subject": "检测您现在的邮箱",
"template_with_brand": "change_mail_confirm_old_template_zh-CN.html",
"template_without_brand": "without-brand/change_mail_confirm_old_template_zh-CN.html",
},
"new_email": {
"subject": "确认您的邮箱地址变更",
"template_with_brand": "change_mail_confirm_new_template_zh-CN.html",
"template_without_brand": "without-brand/change_mail_confirm_new_template_zh-CN.html",
},
},
"en": {
"old_email": {
"subject": "Check your current email",
"template_with_brand": "change_mail_confirm_old_template_en-US.html",
"template_without_brand": "without-brand/change_mail_confirm_old_template_en-US.html",
},
"new_email": {
"subject": "Confirm your new email address",
"template_with_brand": "change_mail_confirm_new_template_en-US.html",
"template_without_brand": "without-brand/change_mail_confirm_new_template_en-US.html",
},
},
}
# send change email mail using different languages
try:
system_features = FeatureService.get_system_features()
lang_key = "zh-Hans" if language == "zh-Hans" else "en"
if phase not in ["old_email", "new_email"]:
raise ValueError("Invalid phase")
config = email_config[lang_key][phase]
subject = config["subject"]
if system_features.branding.enabled:
template = config["template_without_brand"]
else:
template = config["template_with_brand"]
html_content = render_template(template, to=to, code=code)
mail.send(to=to, subject=subject, html=html_content)
end_at = time.perf_counter()
logging.info(
click.style("Send change email mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green")
)
except Exception:
logging.exception("Send change email mail to {} failed".format(to))

@ -0,0 +1,152 @@
import logging
import time
import click
from celery import shared_task # type: ignore
from flask import render_template
from extensions.ext_mail import mail
from services.feature_service import FeatureService
@shared_task(queue="mail")
def send_owner_transfer_confirm_task(language: str, to: str, code: str, workspace: str):
"""
Async Send owner transfer confirm mail
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
:param to: Recipient email address
:param workspace: Workspace name
"""
if not mail.is_inited():
return
logging.info(click.style("Start change email mail to {}".format(to), fg="green"))
start_at = time.perf_counter()
# send change email mail using different languages
try:
if language == "zh-Hans":
template = "transfer_workspace_owner_confirm_template_zh-CN.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
template = "without-brand/transfer_workspace_owner_confirm_template_zh-CN.html"
html_content = render_template(template, to=to, code=code, WorkspaceName=workspace)
mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content)
else:
html_content = render_template(template, to=to, code=code, WorkspaceName=workspace)
mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content)
else:
template = "transfer_workspace_owner_confirm_template_en-US.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
template = "without-brand/transfer_workspace_owner_confirm_template_en-US.html"
html_content = render_template(template, to=to, code=code, WorkspaceName=workspace)
mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content)
else:
html_content = render_template(template, to=to, code=code, WorkspaceName=workspace)
mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content)
end_at = time.perf_counter()
logging.info(
click.style(
"Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at),
fg="green",
)
)
except Exception:
logging.exception("owner transfer confirm email mail to {} failed".format(to))
@shared_task(queue="mail")
def send_old_owner_transfer_notify_email_task(language: str, to: str, workspace: str, new_owner_email: str):
"""
Async Send owner transfer confirm mail
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
:param to: Recipient email address
:param workspace: Workspace name
:param new_owner_email: New owner email
"""
if not mail.is_inited():
return
logging.info(click.style("Start change email mail to {}".format(to), fg="green"))
start_at = time.perf_counter()
# send change email mail using different languages
try:
if language == "zh-Hans":
template = "transfer_workspace_old_owner_notify_template_zh-CN.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
template = "without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html"
html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email)
mail.send(to=to, subject="工作区所有权已转移", html=html_content)
else:
html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email)
mail.send(to=to, subject="工作区所有权已转移", html=html_content)
else:
template = "transfer_workspace_old_owner_notify_template_en-US.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
template = "without-brand/transfer_workspace_old_owner_notify_template_en-US.html"
html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email)
mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content)
else:
html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email)
mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content)
end_at = time.perf_counter()
logging.info(
click.style(
"Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at),
fg="green",
)
)
except Exception:
logging.exception("owner transfer confirm email mail to {} failed".format(to))
@shared_task(queue="mail")
def send_new_owner_transfer_notify_email_task(language: str, to: str, workspace: str):
"""
Async Send owner transfer confirm mail
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
:param to: Recipient email address
:param code: Change email code
:param workspace: Workspace name
"""
if not mail.is_inited():
return
logging.info(click.style("Start change email mail to {}".format(to), fg="green"))
start_at = time.perf_counter()
# send change email mail using different languages
try:
if language == "zh-Hans":
template = "transfer_workspace_new_owner_notify_template_zh-CN.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
template = "without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html"
html_content = render_template(template, to=to, WorkspaceName=workspace)
mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content)
else:
html_content = render_template(template, to=to, WorkspaceName=workspace)
mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content)
else:
template = "transfer_workspace_new_owner_notify_template_en-US.html"
system_features = FeatureService.get_system_features()
if system_features.branding.enabled:
template = "without-brand/transfer_workspace_new_owner_notify_template_en-US.html"
html_content = render_template(template, to=to, WorkspaceName=workspace)
mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content)
else:
html_content = render_template(template, to=to, WorkspaceName=workspace)
mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content)
end_at = time.perf_counter()
logging.info(
click.style(
"Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at),
fg="green",
)
)
except Exception:
logging.exception("owner transfer confirm email mail to {} failed".format(to))

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 454px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">Confirm Your New Email Address</p>
<div class="description">
<p class="content1">Youre updating the email address linked to your Dify account.</p>
<p class="content2">To confirm this action, please use the verification code below.</p>
<p class="content3">This code will only be valid for the next 5 minutes:</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">If you didnt make this request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 454px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">确认您的邮箱地址变更</p>
<div class="description">
<p class="content1">您正在更新与您的 Dify 账户关联的邮箱地址。</p>
<p class="content2">为了确认此操作,请使用以下验证码。</p>
<p class="content3">此验证码仅在接下来的5分钟内有效</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
</div>
</body>
</html>

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 454px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">Verify Your Request to Change Email</p>
<div class="description">
<p class="content1">We received a request to change the email address associated with your Dify account.</p>
<p class="content2">To confirm this action, please use the verification code below.</p>
<p class="content3">This code will only be valid for the next 5 minutes:</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">If you didnt make this request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 454px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">验证您的邮箱变更请求</p>
<div class="description">
<p class="content1">我们收到了一个变更您 Dify 账户关联邮箱地址的请求。</p>
<p class="content2">我们收到了一个变更您 Dify 账户关联邮箱地址的请求。</p>
<p class="content3">此验证码仅在接下来的5分钟内有效</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
</div>
</body>
</html>

@ -6,94 +6,136 @@
<title>Documents Disabled Notification</title>
<style>
body {
font-family: Arial, sans-serif;
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
.container {
width: 504px;
min-height: 638px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
background-color: #eef2fa;
padding: 20px;
text-align: center;
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
height: 40px;
}
.content {
padding: 20px;
line-height: 1.6;
color: #333;
max-width: 63px;
height: auto;
}
.content h1 {
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
color: #222;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.content p {
margin: 10px 0;
.button {
display: inline-block;
width: 480px;
padding: 8px 12px;
color: white;
text-decoration: none;
border-radius: 10px;
text-align: center;
transition: background-color 0.3s ease;
border: 0.5px solid rgba(16, 24, 40, 0.04);
background-color: #155AEF;
box-shadow: 0px -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0px 0px 1px 0px rgba(255, 255, 255, 0.16) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.08) inset, 0px 2px 2px -1px rgba(0, 0, 0, 0.12), 0px 1px 1px -1px rgba(0, 0, 0, 0.12), 0px 0px 0px 0.5px rgba(9, 9, 11, 0.05);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
}
.content ul {
padding-left: 20px;
.button:hover {
background-color: #004AEB;
border: 0.5px solid rgba(16, 24, 40, 0.08);
box-shadow: 0px 1px 2px 0px rgba(9, 9, 11, 0.05);
}
.content ul li {
margin-bottom: 10px;
.content {
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.cta-button, .cta-button:hover, .cta-button:active, .cta-button:visited, .cta-button:focus {
display: block;
margin: 20px auto;
padding: 10px 20px;
background-color: #4e89f9;
color: #ffffff !important;
text-align: center;
text-decoration: none !important;
border-radius: 5px;
width: fit-content;
.content1 {
margin: 0;
padding-top: 24px;
padding-bottom: 12px;
font-weight: 500;
}
.footer {
text-align: center;
padding: 10px;
font-size: 12px;
color: #777;
background-color: #f9f9f9;
.content2 {
margin: 0;
padding-bottom: 12px;
}
.list {
margin: 0;
margin-bottom: 20px;
padding: 16px 24px;
border-radius: 16px;
background-color: #F2F4F7;
list-style-type: none;
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.list li {
margin-bottom: 4px;
}
.list li:last-of-type {
margin-bottom: 0px;
}
</style>
</head>
<body>
<div class="email-container">
<div class="container">
<!-- Header -->
<div class="header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo">
</div>
<!-- Content -->
<h1 class="title">Some Documents in Your Knowledge Base Have Been Disabled</h1>
<div class="content">
<h1>Some Documents in Your Knowledge Base Have Been Disabled</h1>
<p>Dear {{userName}},</p>
<p>
<p class="content1">Dear {{userName}},</p>
<p class="content2">
We're sorry for the inconvenience. To ensure optimal performance, documents
that havent been updated or accessed in the past 30 days have been disabled in
your knowledge bases:
</p>
<ul>
<ul class="list">
{% for item in knowledge_details %}
<li>{{ item }}</li>
{% endfor %}
</ul>
<p>You can re-enable them anytime.</p>
<a href={{url}} class="cta-button">Re-enable in Dify</a>
</div>
<!-- Footer -->
<div class="footer">
Sincerely,<br>
The Dify Team
<p class="content2">You can re-enable them anytime.</p>
<p style="text-align: center; margin: 0; margin-bottom: 44px;">
<a href={{url}} class="button">Re-enable in Dify</a>
</p>
<p class="content2">Best regards,</p>
<p class="content2">Dify Team</p>
</div>
</div>
</body>

@ -1,73 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 560px;
margin: 40px auto;
padding: 20px;
background-color: #F3F4F6;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header img {
max-width: 100px;
height: auto;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2970FF;
color: white;
text-decoration: none;
border-radius: 4px;
text-align: center;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #265DD4;
}
.footer {
font-size: 0.9em;
color: #777777;
margin-top: 30px;
}
.content {
margin-top: 20px;
}
</style>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 444px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.button {
display: inline-block;
width: 480px;
padding: 8px 12px;
color: white;
text-decoration: none;
border-radius: 10px;
text-align: center;
transition: background-color 0.3s ease;
border: 0.5px solid rgba(16, 24, 40, 0.04);
background-color: #155AEF;
box-shadow: 0px -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0px 0px 1px 0px rgba(255, 255, 255, 0.16) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.08) inset, 0px 2px 2px -1px rgba(0, 0, 0, 0.12), 0px 1px 1px -1px rgba(0, 0, 0, 0.12), 0px 0px 0px 0.5px rgba(9, 9, 11, 0.05);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
}
.button:hover {
background-color: #004AEB;
border: 0.5px solid rgba(16, 24, 40, 0.08);
box-shadow: 0px 1px 2px 0px rgba(9, 9, 11, 0.05);
}
.content {
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 24px;
padding-bottom: 12px;
font-weight: 500;
}
.content2 {
margin: 0;
padding-bottom: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo">
</div>
<div class="content">
<p>Dear {{ to }},</p>
<p>{{ inviter_name }} is pleased to invite you to join our workspace on Dify, a platform specifically designed for LLM application development. On Dify, you can explore, create, and collaborate to build and operate AI applications.</p>
<p>Click the button below to log in to Dify and join the workspace.</p>
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
</div>
<div class="footer">
<p>Best regards,</p>
<p>Dify Team</p>
<p>Please do not reply directly to this email; it is automatically sent by the system.</p>
</div>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo">
</div>
<div class="content">
<p class="content1">Dear {{ to }},</p>
<p class="content2">{{ inviter_name }} is pleased to invite you to join our workspace on Dify, a platform specifically designed for LLM application development. On Dify, you can explore, create, and collaborate to build and operate AI applications.</p>
<p class="content2">Click the button below to log in to Dify and join the workspace.</p>
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
<p class="content2">Best regards,</p>
<p class="content2">Dify Team</p>
</div>
</div>
</body>
</html>

@ -1,72 +1,93 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 560px;
margin: 40px auto;
padding: 20px;
background-color: #F3F4F6;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header img {
max-width: 100px;
height: auto;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2970FF;
color: white;
text-decoration: none;
border-radius: 4px;
text-align: center;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #265DD4;
}
.footer {
font-size: 0.9em;
color: #777777;
margin-top: 30px;
}
.content {
margin-top: 20px;
}
</style>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 444px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.button {
display: inline-block;
width: 480px;
padding: 8px 12px;
color: white;
text-decoration: none;
border-radius: 10px;
text-align: center;
transition: background-color 0.3s ease;
border: 0.5px solid rgba(16, 24, 40, 0.04);
background-color: #155AEF;
box-shadow: 0px -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0px 0px 1px 0px rgba(255, 255, 255, 0.16) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.08) inset, 0px 2px 2px -1px rgba(0, 0, 0, 0.12), 0px 1px 1px -1px rgba(0, 0, 0, 0.12), 0px 0px 0px 0.5px rgba(9, 9, 11, 0.05);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
}
.button:hover {
background-color: #004AEB;
border: 0.5px solid rgba(16, 24, 40, 0.08);
box-shadow: 0px 1px 2px 0px rgba(9, 9, 11, 0.05);
}
.content {
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 24px;
padding-bottom: 12px;
font-weight: 500;
}
.content2 {
margin: 0;
padding-bottom: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo">
</div>
<div class="content">
<p>尊敬的 {{ to }}</p>
<p>{{ inviter_name }} 现邀请您加入我们在 Dify 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 Dify 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
<p>点击下方按钮即可登录 Dify 并且加入空间。</p>
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
</div>
<div class="footer">
<p>此致,</p>
<p>Dify 团队</p>
<p>请不要直接回复此电子邮件;由系统自动发送。</p>
</div>
<div class="container">
<div class="header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo">
</div>
<div class="content">
<p class="content1">尊敬的 {{ to }}</p>
<p class="content2">{{ inviter_name }} 现邀请您加入我们在 Dify 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 Dify 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
<p class="content2">点击下方按钮即可登录 Dify 并且加入空间。</p>
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
<p class="content2">此致,</p>
<p class="content2">Dify 团队</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 374px;
margin: 40px auto;
padding: 0 48px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">You are now the owner of {{WorkspaceName}}</p>
<div class="description">
<p class="content1">You have been assigned as the new owner of the workspace "{{WorkspaceName}}".</p>
<p class="content2">As the new owner, you now have full administrative privileges for this workspace.</p>
<p class="content3">If you have any questions, please contact support@dify.ai.</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 374px;
margin: 40px auto;
padding: 0 48px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">您现在是 {{WorkspaceName}} 的所有者</p>
<div class="description">
<p class="content1">您已被分配为工作空间“{{WorkspaceName}}”的新所有者。</p>
<p class="content2">作为新所有者,您现在对该工作空间拥有完全的管理权限。</p>
<p class="content3">如果您有任何问题请联系support@dify.ai。</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 394px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">Workspace ownership has been transferred</p>
<div class="description">
<p class="content1">You have successfully transferred ownership of the workspace "{{WorkspaceName}}" to {{NewOwnerEmail}}.</p>
<p class="content2">You no longer have owner privileges for this workspace. Your access level has been changed to Admin.</p>
<p class="content3">If you did not initiate this transfer or have concerns about this change, please contact support@dify.ai immediately.</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 394px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">工作区所有权已转移</p>
<div class="description">
<p class="content1">您已成功将工作空间“{{WorkspaceName}}”的所有权转移给{{NewOwnerEmail}}。</p>
<p class="content2">您不再拥有此工作空间的拥有者权限。您的访问级别已更改为管理员。</p>
<p class="content3">如果您没有发起此转移或对此变更有任何疑问请立即联系support@dify.ai。</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,153 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 600px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.warning {
padding-top: 12px;
padding-bottom: 4px;
color: #101828;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
}
.warningList {
margin: 0;
padding-left: 21px;
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">Verify Your Request to Transfer Workspace Ownership</p>
<div class="description">
<p class="content1">We received a request to transfer ownership of your workspace “{{WorkspaceName}}”.</p>
<p class="content2">To confirm this action, please use the verification code below.</p>
<p class="content3">This code will only be valid for the next 5 minutes:</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<div class="warning">Please note:</div>
<ul class="warningList">
<li>The ownership transfer will take effect immediately once confirmed and cannot be undone.</li>
<li>Youll become a admin member, and the new owner will have full control of the workspace.</li>
</ul>
<p class="tips">If you didnt make this request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

@ -0,0 +1,153 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 600px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.warning {
padding-top: 12px;
padding-bottom: 4px;
color: #101828;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
}
.warningList {
margin: 0;
padding-left: 21px;
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<p class="title">验证您的工作空间所有权转移请求</p>
<div class="description">
<p class="content1">我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移的请求。</p>
<p class="content2">为了确认此操作,请使用以下验证码。</p>
<p class="content3">此验证码仅在5分钟内有效</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<div class="warning">请注意:</div>
<ul class="warningList">
<li>所有权转移一旦确认将立即生效且无法撤销。</li>
<li>您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。</li>
</ul>
<p class="tips">如果您没有发起此请求,请忽略此邮件或立即联系客服。</p>
</div>
</body>
</html>

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 454px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">Confirm Your New Email Address</p>
<div class="description">
<p class="content1">Youre updating the email address linked to your Dify account.</p>
<p class="content2">To confirm this action, please use the verification code below.</p>
<p class="content3">This code will only be valid for the next 5 minutes:</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">If you didnt make this request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 454px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">确认您的邮箱地址变更</p>
<div class="description">
<p class="content1">您正在更新与您的 Dify 账户关联的邮箱地址。</p>
<p class="content2">为了确认此操作,请使用以下验证码。</p>
<p class="content3">此验证码仅在接下来的5分钟内有效</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
</div>
</body>
</html>

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 454px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">Verify Your Request to Change Email</p>
<div class="description">
<p class="content1">We received a request to change the email address associated with your Dify account.</p>
<p class="content2">To confirm this action, please use the verification code below.</p>
<p class="content3">This code will only be valid for the next 5 minutes:</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">If you didnt make this request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 454px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">验证您的邮箱变更请求</p>
<div class="description">
<p class="content1">我们收到了一个变更您 Dify 账户关联邮箱地址的请求。</p>
<p class="content2">我们收到了一个变更您 Dify 账户关联邮箱地址的请求。</p>
<p class="content3">此验证码仅在接下来的5分钟内有效</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
</div>
</body>
</html>

@ -1,69 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 560px;
margin: 40px auto;
padding: 20px;
background-color: #F3F4F6;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header img {
max-width: 100px;
height: auto;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2970FF;
color: white;
text-decoration: none;
border-radius: 4px;
text-align: center;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #265DD4;
}
.footer {
font-size: 0.9em;
color: #777777;
margin-top: 30px;
}
.content {
margin-top: 20px;
}
</style>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 444px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.button {
display: inline-block;
width: 480px;
padding: 8px 12px;
color: white;
text-decoration: none;
border-radius: 10px;
text-align: center;
transition: background-color 0.3s ease;
border: 0.5px solid rgba(16, 24, 40, 0.04);
background-color: #155AEF;
box-shadow: 0px -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0px 0px 1px 0px rgba(255, 255, 255, 0.16) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.08) inset, 0px 2px 2px -1px rgba(0, 0, 0, 0.12), 0px 1px 1px -1px rgba(0, 0, 0, 0.12), 0px 0px 0px 0.5px rgba(9, 9, 11, 0.05);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
}
.button:hover {
background-color: #004AEB;
border: 0.5px solid rgba(16, 24, 40, 0.08);
box-shadow: 0px 1px 2px 0px rgba(9, 9, 11, 0.05);
}
.content {
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 24px;
padding-bottom: 12px;
font-weight: 500;
}
.content2 {
margin: 0;
padding-bottom: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<p>Dear {{ to }},</p>
<p>{{ inviter_name }} is pleased to invite you to join our workspace on {{application_title}}, a platform specifically designed for LLM application development. On {{application_title}}, you can explore, create, and collaborate to build and operate AI applications.</p>
<p>Click the button below to log in to {{application_title}} and join the workspace.</p>
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
</div>
<div class="footer">
<p>Best regards,</p>
<p>{{application_title}} Team</p>
<p>Please do not reply directly to this email; it is automatically sent by the system.</p>
</div>
<div class="container">
<div class="header">
<!-- Optional: Add a logo or a header image here -->
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo">
</div>
<div class="content">
<p class="content1">Dear {{ to }},</p>
<p class="content2">{{ inviter_name }} is pleased to invite you to join our workspace on {{application_title}}, a platform specifically designed for LLM application development. On {{application_title}}, you can explore, create, and collaborate to build and operate AI applications.</p>
<p class="content2">Click the button below to log in to {{application_title}} and join the workspace.</p>
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
<p class="content2">Best regards,</p>
<p class="content2">{{application_title}} Team</p>
</div>
</div>
</body>
</html>

@ -1,69 +1,91 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 560px;
margin: 40px auto;
padding: 20px;
background-color: #F3F4F6;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header img {
max-width: 100px;
height: auto;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2970FF;
color: white;
text-decoration: none;
border-radius: 4px;
text-align: center;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #265DD4;
}
.footer {
font-size: 0.9em;
color: #777777;
margin-top: 30px;
}
.content {
margin-top: 20px;
}
</style>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #374151;
background-color: #E5E7EB;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 444px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.button {
display: inline-block;
width: 480px;
padding: 8px 12px;
color: white;
text-decoration: none;
border-radius: 10px;
text-align: center;
transition: background-color 0.3s ease;
border: 0.5px solid rgba(16, 24, 40, 0.04);
background-color: #155AEF;
box-shadow: 0px -6px 12px -4px rgba(9, 9, 11, 0.08) inset, 0px 0px 1px 0px rgba(255, 255, 255, 0.16) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.08) inset, 0px 2px 2px -1px rgba(0, 0, 0, 0.12), 0px 1px 1px -1px rgba(0, 0, 0, 0.12), 0px 0px 0px 0.5px rgba(9, 9, 11, 0.05);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
}
.button:hover {
background-color: #004AEB;
border: 0.5px solid rgba(16, 24, 40, 0.08);
box-shadow: 0px 1px 2px 0px rgba(9, 9, 11, 0.05);
}
.content {
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 24px;
padding-bottom: 12px;
font-weight: 500;
}
.content2 {
margin: 0;
padding-bottom: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<p>尊敬的 {{ to }}</p>
<p>{{ inviter_name }} 现邀请您加入我们在 {{application_title}} 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 {{application_title}} 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
<p>点击下方按钮即可登录 {{application_title}} 并且加入空间。</p>
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
</div>
<div class="footer">
<p>此致,</p>
<p>{{application_title}} 团队</p>
<p>请不要直接回复此电子邮件;由系统自动发送。</p>
</div>
<div class="container">
<div class="header"></div>
<div class="content">
<p class="content1">尊敬的 {{ to }}</p>
<p class="content2">{{ inviter_name }} 现邀请您加入我们在 {{application_title}} 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 {{application_title}} 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
<p class="content2">点击下方按钮即可登录 {{application_title}} 并且加入空间。</p>
<p style="text-align: center; margin: 0; margin-bottom: 32px;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
<p class="content2">此致,</p>
<p class="content2">{{application_title}} 团队</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 374px;
margin: 40px auto;
padding: 0 48px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">You are now the owner of {{WorkspaceName}}</p>
<div class="description">
<p class="content1">You have been assigned as the new owner of the workspace "{{WorkspaceName}}".</p>
<p class="content2">As the new owner, you now have full administrative privileges for this workspace.</p>
<p class="content3">If you have any questions, please contact support@dify.ai.</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 374px;
margin: 40px auto;
padding: 0 48px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">您现在是 {{WorkspaceName}} 的所有者</p>
<div class="description">
<p class="content1">您已被分配为工作空间“{{WorkspaceName}}”的新所有者。</p>
<p class="content2">作为新所有者,您现在对该工作空间拥有完全的管理权限。</p>
<p class="content3">如果您有任何问题请联系support@dify.ai。</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 394px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">Workspace ownership has been transferred</p>
<div class="description">
<p class="content1">You have successfully transferred ownership of the workspace "{{WorkspaceName}}" to {{NewOwnerEmail}}.</p>
<p class="content2">You no longer have owner privileges for this workspace. Your access level has been changed to Admin.</p>
<p class="content3">If you did not initiate this transfer or have concerns about this change, please contact support@dify.ai immediately.</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 394px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">工作区所有权已转移</p>
<div class="description">
<p class="content1">您已成功将工作空间“{{WorkspaceName}}”的所有权转移给{{NewOwnerEmail}}。</p>
<p class="content2">您不再拥有此工作空间的拥有者权限。您的访问级别已更改为管理员。</p>
<p class="content3">如果您没有发起此转移或对此变更有任何疑问请立即联系support@dify.ai。</p>
</div>
</div>
</body>
</html>

@ -0,0 +1,150 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 600px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.warning {
padding-top: 12px;
padding-bottom: 4px;
color: #101828;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
}
.warningList {
margin: 0;
padding-left: 21px;
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">Verify Your Request to Transfer Workspace Ownership</p>
<div class="description">
<p class="content1">We received a request to transfer ownership of your workspace “{{WorkspaceName}}”.</p>
<p class="content2">To confirm this action, please use the verification code below.</p>
<p class="content3">This code will only be valid for the next 5 minutes:</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<div class="warning">Please note:</div>
<ul class="warningList">
<li>The ownership transfer will take effect immediately once confirmed and cannot be undone.</li>
<li>Youll become a admin member, and the new owner will have full control of the workspace.</li>
</ul>
<p class="tips">If you didnt make this request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

@ -0,0 +1,150 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 504px;
height: 600px;
margin: 40px auto;
padding: 0 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
}
.header {
padding-top: 36px;
padding-bottom: 24px;
}
.header img {
max-width: 63px;
height: auto;
}
.title {
margin: 0;
padding-top: 8px;
padding-bottom: 16px;
color: #101828;
font-size: 24px;
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
}
.description {
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.content1 {
margin: 0;
padding-top: 16px;
padding-bottom: 12px;
}
.content2 {
margin: 0;
}
.content3 {
margin: 0;
padding-bottom: 12px;
}
.code-content {
margin-bottom: 8px;
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
}
.code {
color: #101828;
font-family: Inter;
font-size: 30px;
font-style: normal;
font-weight: 700;
line-height: 36px;
}
.warning {
padding-top: 12px;
padding-bottom: 4px;
color: #101828;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
}
.warningList {
margin: 0;
padding-left: 21px;
color: #354052;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.tips {
margin: 0;
padding-top: 12px;
padding-bottom: 16px;
color: #354052;
font-size: 14px;
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
</style>
</head>
<body>
<div class="container">
<div class="header"></div>
<p class="title">验证您的工作空间所有权转移请求</p>
<div class="description">
<p class="content1">我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移的请求。</p>
<p class="content2">为了确认此操作,请使用以下验证码。</p>
<p class="content3">此验证码仅在5分钟内有效</p>
</div>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<div class="warning">请注意:</div>
<ul class="warningList">
<li>所有权转移一旦确认将立即生效且无法撤销。</li>
<li>您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。</li>
</ul>
<p class="tips">如果您没有发起此请求,请忽略此邮件或立即联系客服。</p>
</div>
</body>
</html>

@ -203,6 +203,8 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
# Reset password token expiry minutes
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
CREATE_TIDB_SERVICE_JOB_ENABLED=false

@ -354,3 +354,35 @@ def test_execute_code_output_object_list():
# validate
with pytest.raises(ValueError):
node._transform_result(result, node.node_data.outputs)
def test_execute_code_scientific_notation():
code = """
def main() -> dict:
return {
"result": -8.0E-5
}
"""
code = "\n".join([line[4:] for line in code.split("\n")])
code_config = {
"id": "code",
"data": {
"outputs": {
"result": {
"type": "number",
},
},
"title": "123",
"variables": [],
"answer": "123",
"code_language": "python3",
"code": code,
},
}
node = init_code_node(code_config)
# execute node
result = node._run()
assert isinstance(result, NodeRunResult)
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED

@ -1,194 +0,0 @@
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
import pytest
from core.helper.url_signer import SignedUrlParams, UrlSigner
class TestUrlSigner:
"""Test cases for UrlSigner class"""
@patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
def test_should_generate_signed_url_params(self):
"""Test generation of signed URL parameters with all required fields"""
sign_key = "test-sign-key"
prefix = "test-prefix"
params = UrlSigner.get_signed_url_params(sign_key, prefix)
# Verify the returned object and required fields
assert isinstance(params, SignedUrlParams)
assert params.sign_key == sign_key
assert params.timestamp is not None
assert params.nonce is not None
assert params.sign is not None
# Verify nonce format (32 character hex string)
assert len(params.nonce) == 32
assert all(c in "0123456789abcdef" for c in params.nonce)
@patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
def test_should_generate_complete_signed_url(self):
"""Test generation of complete signed URL with query parameters"""
base_url = "https://example.com/api/test"
sign_key = "test-sign-key"
prefix = "test-prefix"
signed_url = UrlSigner.get_signed_url(base_url, sign_key, prefix)
# Parse URL and verify structure
parsed = urlparse(signed_url)
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == base_url
# Verify query parameters
query_params = parse_qs(parsed.query)
assert "timestamp" in query_params
assert "nonce" in query_params
assert "sign" in query_params
# Verify each parameter has exactly one value
assert len(query_params["timestamp"]) == 1
assert len(query_params["nonce"]) == 1
assert len(query_params["sign"]) == 1
# Verify parameter values are not empty
assert query_params["timestamp"][0]
assert query_params["nonce"][0]
assert query_params["sign"][0]
@patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
def test_should_verify_valid_signature(self):
"""Test verification of valid signature"""
sign_key = "test-sign-key"
prefix = "test-prefix"
# Generate and verify signature
params = UrlSigner.get_signed_url_params(sign_key, prefix)
is_valid = UrlSigner.verify(
sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix=prefix
)
assert is_valid is True
@patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
@pytest.mark.parametrize(
("field", "modifier"),
[
("sign_key", lambda _: "wrong-sign-key"),
("timestamp", lambda t: str(int(t) + 1000)),
("nonce", lambda _: "different-nonce-123456789012345"),
("prefix", lambda _: "wrong-prefix"),
("sign", lambda s: s + "tampered"),
],
)
def test_should_reject_invalid_signature_params(self, field, modifier):
"""Test signature verification rejects invalid parameters"""
sign_key = "test-sign-key"
prefix = "test-prefix"
# Generate valid signed parameters
params = UrlSigner.get_signed_url_params(sign_key, prefix)
# Prepare verification parameters
verify_params = {
"sign_key": sign_key,
"timestamp": params.timestamp,
"nonce": params.nonce,
"sign": params.sign,
"prefix": prefix,
}
# Modify the specific field
verify_params[field] = modifier(verify_params[field])
# Verify should fail
is_valid = UrlSigner.verify(**verify_params)
assert is_valid is False
@patch("configs.dify_config.SECRET_KEY", None)
def test_should_raise_error_without_secret_key(self):
"""Test that signing fails when SECRET_KEY is not configured"""
with pytest.raises(Exception) as exc_info:
UrlSigner.get_signed_url_params("key", "prefix")
assert "SECRET_KEY is not set" in str(exc_info.value)
@patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
def test_should_generate_unique_signatures(self):
"""Test that different inputs produce different signatures"""
params1 = UrlSigner.get_signed_url_params("key1", "prefix1")
params2 = UrlSigner.get_signed_url_params("key2", "prefix2")
# Different inputs should produce different signatures
assert params1.sign != params2.sign
assert params1.nonce != params2.nonce
@patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
def test_should_handle_special_characters(self):
"""Test handling of special characters in parameters"""
special_cases = [
"test with spaces",
"test/with/slashes",
"test中文字符",
]
for sign_key in special_cases:
params = UrlSigner.get_signed_url_params(sign_key, "prefix")
# Should generate valid signature and verify correctly
is_valid = UrlSigner.verify(
sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix="prefix"
)
assert is_valid is True
@patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
def test_should_ensure_nonce_randomness(self):
"""Test that nonce is random for each generation - critical for security"""
sign_key = "test-sign-key"
prefix = "test-prefix"
# Generate multiple nonces
nonces = set()
for _ in range(5):
params = UrlSigner.get_signed_url_params(sign_key, prefix)
nonces.add(params.nonce)
# All nonces should be unique
assert len(nonces) == 5
@patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
@patch("time.time", return_value=1234567890)
@patch("os.urandom", return_value=b"\xab\xcd\xef\x12\x34\x56\x78\x90\xab\xcd\xef\x12\x34\x56\x78\x90")
def test_should_produce_consistent_signatures(self, mock_urandom, mock_time):
"""Test that same inputs produce same signature - ensures deterministic behavior"""
sign_key = "test-sign-key"
prefix = "test-prefix"
# Generate signature multiple times with same inputs (time and nonce are mocked)
params1 = UrlSigner.get_signed_url_params(sign_key, prefix)
params2 = UrlSigner.get_signed_url_params(sign_key, prefix)
# With mocked time and random, should produce identical results
assert params1.timestamp == params2.timestamp
assert params1.nonce == params2.nonce
assert params1.sign == params2.sign
# Verify the signature is valid
assert UrlSigner.verify(
sign_key=sign_key, timestamp=params1.timestamp, nonce=params1.nonce, sign=params1.sign, prefix=prefix
)
@patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
def test_should_handle_empty_strings(self):
"""Test handling of empty string parameters - common edge case"""
# Empty sign_key and prefix should still work
params = UrlSigner.get_signed_url_params("", "")
assert params.sign is not None
# Should verify correctly
is_valid = UrlSigner.verify(
sign_key="", timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix=""
)
assert is_valid is True

@ -376,7 +376,7 @@ class TestSegmentDumpAndLoad:
f"get_segment_discriminator failed for serialized form of type {type(variable)}"
)
def test_invlaid_value_for_discriminator(self):
def test_invalid_value_for_discriminator(self):
# Test invalid cases
assert get_segment_discriminator({"value_type": "invalid"}) is None
assert get_segment_discriminator({}) is None

@ -772,6 +772,8 @@ INVITE_EXPIRY_HOURS=72
# Reset password token valid time (minutes),
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
# The sandbox service endpoint.
CODE_EXECUTION_ENDPOINT=http://sandbox:8194
@ -1139,6 +1141,8 @@ PLUGIN_VOLCENGINE_TOS_REGION=
# OTLP Collector Configuration
# ------------------------------
ENABLE_OTEL=false
OTLP_TRACE_ENDPOINT=
OTLP_METRIC_ENDPOINT=
OTLP_BASE_ENDPOINT=http://localhost:4318
OTLP_API_KEY=
OTEL_EXPORTER_OTLP_PROTOCOL=

@ -335,6 +335,8 @@ x-shared-env: &shared-api-worker-env
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5}
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5}
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5}
CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194}
CODE_EXECUTION_API_KEY: ${CODE_EXECUTION_API_KEY:-dify-sandbox}
CODE_MAX_NUMBER: ${CODE_MAX_NUMBER:-9223372036854775807}
@ -506,6 +508,8 @@ x-shared-env: &shared-api-worker-env
PLUGIN_VOLCENGINE_TOS_SECRET_KEY: ${PLUGIN_VOLCENGINE_TOS_SECRET_KEY:-}
PLUGIN_VOLCENGINE_TOS_REGION: ${PLUGIN_VOLCENGINE_TOS_REGION:-}
ENABLE_OTEL: ${ENABLE_OTEL:-false}
OTLP_TRACE_ENDPOINT: ${OTLP_TRACE_ENDPOINT:-}
OTLP_METRIC_ENDPOINT: ${OTLP_METRIC_ENDPOINT:-}
OTLP_BASE_ENDPOINT: ${OTLP_BASE_ENDPOINT:-http://localhost:4318}
OTLP_API_KEY: ${OTLP_API_KEY:-}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-}

@ -1,16 +1,18 @@
import type { FC } from 'react'
import React from 'react'
import Main from '@/app/components/explore/installed-app'
export type IInstalledAppProps = {
params: Promise<{
params: {
appId: string
}>
}
}
const InstalledApp: FC<IInstalledAppProps> = async ({ params }) => {
// Using Next.js page convention for async server components
async function InstalledApp({ params }: IInstalledAppProps) {
const appId = (await params).appId
return (
<Main id={(await params).appId} />
<Main id={appId} />
)
}
export default React.memo(InstalledApp)
export default InstalledApp

@ -1,10 +1,13 @@
'use client'
import React from 'react'
import ChatWithHistoryWrap from '@/app/components/base/chat/chat-with-history'
import AuthenticatedLayout from '../../components/authenticated-layout'
const Chat = () => {
return (
<ChatWithHistoryWrap />
<AuthenticatedLayout>
<ChatWithHistoryWrap />
</AuthenticatedLayout>
)
}

@ -1,10 +1,13 @@
'use client'
import React from 'react'
import EmbeddedChatbot from '@/app/components/base/chat/embedded-chatbot'
import AuthenticatedLayout from '../../components/authenticated-layout'
const Chatbot = () => {
return (
<EmbeddedChatbot />
<AuthenticatedLayout>
<EmbeddedChatbot />
</AuthenticatedLayout>
)
}

@ -1,9 +1,12 @@
import React from 'react'
import Main from '@/app/components/share/text-generation'
import AuthenticatedLayout from '../../components/authenticated-layout'
const Completion = () => {
return (
<Main />
<AuthenticatedLayout>
<Main />
</AuthenticatedLayout>
)
}

@ -0,0 +1,84 @@
'use client'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { removeAccessToken } from '@/app/components/share/utils'
import { useWebAppStore } from '@/context/web-app-context'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import React, { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation()
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
const updateAppParams = useWebAppStore(s => s.updateAppParams)
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetWebAppParams()
const { isFetching: isFetchingAppInfo, data: appInfo, error: appInfoError } = useGetWebAppInfo()
const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetWebAppMeta()
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp: false })
useEffect(() => {
if (appInfo)
updateAppInfo(appInfo)
if (appParams)
updateAppParams(appParams)
if (appMeta)
updateWebAppMeta(appMeta)
updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result))
}, [appInfo, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp])
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
params.set('redirect_url', pathname)
return `/webapp-signin?${params.toString()}`
}, [searchParams, pathname])
const backToHome = useCallback(() => {
removeAccessToken()
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router])
if (appInfoError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appInfoError.message} />
</div>
}
if (appParamsError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appParamsError.message} />
</div>
}
if (appMetaError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appMetaError.message} />
</div>
}
if (useCanAccessAppError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={useCanAccessAppError.message} />
</div>
}
if (userCanAccessApp && !userCanAccessApp.result) {
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>
</div>
}
if (isFetchingAppInfo || isFetchingAppParams || isFetchingAppMeta) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
}
return <>{children}</>
}
export default React.memo(AuthenticatedLayout)

@ -0,0 +1,80 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import { useEffect } from 'react'
import { useCallback } from 'react'
import { useWebAppStore } from '@/context/web-app-context'
import { useRouter, useSearchParams } from 'next/navigation'
import AppUnavailable from '@/app/components/base/app-unavailable'
import { checkOrSetAccessToken, removeAccessToken, setAccessToken } from '@/app/components/share/utils'
import { useTranslation } from 'react-i18next'
import { fetchAccessToken } from '@/service/share'
import Loading from '@/app/components/base/loading'
import { AccessMode } from '@/models/access-control'
const Splash: FC<PropsWithChildren> = ({ children }) => {
const { t } = useTranslation()
const shareCode = useWebAppStore(s => s.shareCode)
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
const searchParams = useSearchParams()
const router = useRouter()
const redirectUrl = searchParams.get('redirect_url')
const tokenFromUrl = searchParams.get('web_sso_token')
const message = searchParams.get('message')
const code = searchParams.get('code')
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
params.delete('code')
return `/webapp-signin?${params.toString()}`
}, [searchParams])
const backToHome = useCallback(() => {
removeAccessToken()
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router])
useEffect(() => {
(async () => {
if (message)
return
if (shareCode && tokenFromUrl && redirectUrl) {
localStorage.setItem('webapp_access_token', tokenFromUrl)
const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: tokenFromUrl })
await setAccessToken(shareCode, tokenResp.access_token)
router.replace(decodeURIComponent(redirectUrl))
return
}
if (shareCode && redirectUrl && localStorage.getItem('webapp_access_token')) {
const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: localStorage.getItem('webapp_access_token') })
await setAccessToken(shareCode, tokenResp.access_token)
router.replace(decodeURIComponent(redirectUrl))
return
}
if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) {
await checkOrSetAccessToken(shareCode)
router.replace(decodeURIComponent(redirectUrl))
}
})()
}, [shareCode, redirectUrl, router, tokenFromUrl, message, webAppAccessMode])
if (message) {
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
<AppUnavailable className='h-auto w-auto' code={code || t('share.common.appUnavailable')} unknownReason={message} />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')}</span>
</div>
}
if (tokenFromUrl) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
}
if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
}
return <>{children}</>
}
export default Splash

@ -1,54 +1,15 @@
'use client'
import React, { useEffect, useState } from 'react'
import type { FC } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import Loading from '../components/base/loading'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode } from '@/models/access-control'
import { getAppAccessModeByAppCode } from '@/service/share'
import type { FC, PropsWithChildren } from 'react'
import WebAppStoreProvider from '@/context/web-app-context'
import Splash from './components/splash'
const Layout: FC<{
children: React.ReactNode
}> = ({ children }) => {
const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending)
const setWebAppAccessMode = useGlobalPublicStore(s => s.setWebAppAccessMode)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const pathname = usePathname()
const searchParams = useSearchParams()
const redirectUrl = searchParams.get('redirect_url')
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
(async () => {
if (!isGlobalPending && !systemFeatures.webapp_auth.enabled) {
setIsLoading(false)
return
}
let appCode: string | null = null
if (redirectUrl) {
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
appCode = url.pathname.split('/').pop() || null
}
else {
appCode = pathname.split('/').pop() || null
}
if (!appCode)
return
setIsLoading(true)
const ret = await getAppAccessModeByAppCode(appCode)
setWebAppAccessMode(ret?.accessMode || AccessMode.PUBLIC)
setIsLoading(false)
})()
}, [pathname, redirectUrl, setWebAppAccessMode, isGlobalPending, systemFeatures.webapp_auth.enabled])
if (isLoading || isGlobalPending) {
return <div className='flex h-full w-full items-center justify-center'>
<Loading />
</div>
}
const Layout: FC<PropsWithChildren> = ({ children }) => {
return (
<div className="h-full min-w-[300px] pb-[env(safe-area-inset-bottom)]">
{children}
<WebAppStoreProvider>
<Splash>
{children}
</Splash>
</WebAppStoreProvider>
</div>
)
}

@ -3,10 +3,13 @@
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import type { PropsWithChildren } from 'react'
import { useTranslation } from 'react-i18next'
export default function SignInLayout({ children }: any) {
const { systemFeatures } = useGlobalPublicStore()
useDocumentTitle('')
export default function SignInLayout({ children }: PropsWithChildren) {
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
useDocumentTitle(t('login.webapp.login'))
return <>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>

@ -1,3 +1,4 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'

@ -1,36 +1,30 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import type { FC } from 'react'
import React, { useCallback, useEffect } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { removeAccessToken, setAccessToken } from '@/app/components/share/utils'
import { removeAccessToken } from '@/app/components/share/utils'
import { useGlobalPublicStore } from '@/context/global-public-context'
import Loading from '@/app/components/base/loading'
import AppUnavailable from '@/app/components/base/app-unavailable'
import NormalForm from './normalForm'
import { AccessMode } from '@/models/access-control'
import ExternalMemberSsoAuth from './components/external-member-sso-auth'
import { fetchAccessToken } from '@/service/share'
import { useWebAppStore } from '@/context/web-app-context'
const WebSSOForm: FC = () => {
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode)
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
const searchParams = useSearchParams()
const router = useRouter()
const redirectUrl = searchParams.get('redirect_url')
const tokenFromUrl = searchParams.get('web_sso_token')
const message = searchParams.get('message')
const code = searchParams.get('code')
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
params.delete('code')
const params = new URLSearchParams()
params.append('redirect_url', redirectUrl || '')
return `/webapp-signin?${params.toString()}`
}, [searchParams])
}, [redirectUrl])
const backToHome = useCallback(() => {
removeAccessToken()
@ -38,73 +32,12 @@ const WebSSOForm: FC = () => {
router.replace(url)
}, [getSigninUrl, router])
const showErrorToast = (msg: string) => {
Toast.notify({
type: 'error',
message: msg,
})
}
const getAppCodeFromRedirectUrl = useCallback(() => {
if (!redirectUrl)
return null
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
const appCode = url.pathname.split('/').pop()
if (!appCode)
return null
return appCode
}, [redirectUrl])
useEffect(() => {
(async () => {
if (message)
return
const appCode = getAppCodeFromRedirectUrl()
if (appCode && tokenFromUrl && redirectUrl) {
localStorage.setItem('webapp_access_token', tokenFromUrl)
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: tokenFromUrl })
await setAccessToken(appCode, tokenResp.access_token)
router.replace(decodeURIComponent(redirectUrl))
return
}
if (appCode && redirectUrl && localStorage.getItem('webapp_access_token')) {
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: localStorage.getItem('webapp_access_token') })
await setAccessToken(appCode, tokenResp.access_token)
router.replace(decodeURIComponent(redirectUrl))
}
})()
}, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl, message])
useEffect(() => {
if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC && redirectUrl)
router.replace(decodeURIComponent(redirectUrl))
}, [webAppAccessMode, router, redirectUrl])
if (tokenFromUrl) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
}
if (message) {
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
<AppUnavailable className='h-auto w-auto' code={code || t('share.common.appUnavailable')} unknownReason={message} />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')}</span>
</div>
}
if (!redirectUrl) {
showErrorToast('redirect url is invalid.')
return <div className='flex h-full items-center justify-center'>
<AppUnavailable code={t('share.common.appUnavailable')} unknownReason='redirect url is invalid.' />
</div>
}
if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
}
if (!systemFeatures.webapp_auth.enabled) {
return <div className="flex h-full items-center justify-center">
<p className='system-xs-regular text-text-tertiary'>{t('login.webapp.disabled')}</p>

@ -1,10 +1,13 @@
import React from 'react'
import Main from '@/app/components/share/text-generation'
import AuthenticatedLayout from '../../components/authenticated-layout'
const Workflow = () => {
return (
<Main isWorkflow />
<AuthenticatedLayout>
<Main isWorkflow />
</AuthenticatedLayout>
)
}

@ -0,0 +1,371 @@
import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { useContext } from 'use-context-selector'
import { ToastContext } from '@/app/components/base/toast'
import { RiCloseLine } from '@remixicon/react'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import {
checkEmailExisted,
logout,
resetEmail,
sendVerifyCode,
verifyEmail,
} from '@/service/common'
import { noop } from 'lodash-es'
type Props = {
show: boolean
onClose: () => void
email: string
}
enum STEP {
start = 'start',
verifyOrigin = 'verifyOrigin',
newEmail = 'newEmail',
verifyNew = 'verifyNew',
}
const EmailChangeModal = ({ onClose, email, show }: Props) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const router = useRouter()
const [step, setStep] = useState<STEP>(STEP.start)
const [code, setCode] = useState<string>('')
const [mail, setMail] = useState<string>('')
const [time, setTime] = useState<number>(0)
const [stepToken, setStepToken] = useState<string>('')
const [newEmailExited, setNewEmailExited] = useState<boolean>(false)
const [isCheckingEmail, setIsCheckingEmail] = useState<boolean>(false)
const startCount = () => {
setTime(60)
const timer = setInterval(() => {
setTime((prev) => {
if (prev <= 0) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
}
const sendEmail = async (email: string, isOrigin: boolean, token?: string) => {
try {
const res = await sendVerifyCode({
email,
phase: isOrigin ? 'old_email' : 'new_email',
token,
})
startCount()
if (res.data)
setStepToken(res.data)
}
catch (error) {
notify({
type: 'error',
message: `Error sending verification code: ${error ? (error as any).message : ''}`,
})
}
}
const verifyEmailAddress = async (email: string, code: string, token: string, callback?: (data?: any) => void) => {
try {
const res = await verifyEmail({
email,
code,
token,
})
if (res.is_valid) {
setStepToken(res.token)
callback?.(res.token)
}
else {
notify({
type: 'error',
message: 'Verifying email failed',
})
}
}
catch (error) {
notify({
type: 'error',
message: `Error verifying email: ${error ? (error as any).message : ''}`,
})
}
}
const sendCodeToOriginEmail = async () => {
await sendEmail(
email,
true,
)
setStep(STEP.verifyOrigin)
}
const handleVerifyOriginEmail = async () => {
await verifyEmailAddress(email, code, stepToken, () => setStep(STEP.newEmail))
setCode('')
}
const isValidEmail = (email: string): boolean => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return emailRegex.test(email)
}
const checkNewEmailExisted = async (email: string) => {
setIsCheckingEmail(true)
try {
await checkEmailExisted({
email,
})
setNewEmailExited(false)
}
catch {
setNewEmailExited(true)
}
finally {
setIsCheckingEmail(false)
}
}
const handleNewEmailValueChange = (mailAddress: string) => {
setMail(mailAddress)
setNewEmailExited(false)
if (isValidEmail(mailAddress))
checkNewEmailExisted(mailAddress)
}
const sendCodeToNewEmail = async () => {
if (!isValidEmail(mail)) {
notify({
type: 'error',
message: 'Invalid email format',
})
return
}
await sendEmail(
mail,
false,
stepToken,
)
setStep(STEP.verifyNew)
}
const handleLogout = async () => {
await logout({
url: '/logout',
params: {},
})
localStorage.removeItem('setup_status')
localStorage.removeItem('console_token')
localStorage.removeItem('refresh_token')
router.push('/signin')
}
const updateEmail = async (lastToken: string) => {
try {
await resetEmail({
new_email: mail,
token: lastToken,
})
handleLogout()
}
catch (error) {
notify({
type: 'error',
message: `Error changing email: ${error ? (error as any).message : ''}`,
})
}
}
const submitNewEmail = async () => {
await verifyEmailAddress(mail, code, stepToken, updateEmail)
}
return (
<Modal
isShow={show}
onClose={noop}
className='!w-[420px] !p-6'
>
<div className='absolute right-5 top-5 cursor-pointer p-1.5' onClick={onClose}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
{step === STEP.start && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.title')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-medium text-text-warning'>{t('common.account.changeEmail.authTip')}</div>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.account.changeEmail.content1"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email }}
/>
</div>
</div>
<div className='pt-3'></div>
<div className='space-y-2'>
<Button
className='!w-full'
variant='primary'
onClick={sendCodeToOriginEmail}
>
{t('common.account.changeEmail.sendVerifyCode')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
{step === STEP.verifyOrigin && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyEmail')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.account.changeEmail.content2"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email }}
/>
</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.codePlaceholder')}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={code.length !== 6}
className='!w-full'
variant='primary'
onClick={handleVerifyOriginEmail}
>
{t('common.account.changeEmail.continue')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
<span>{t('common.account.changeEmail.resendTip')}</span>
{time > 0 && (
<span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
)}
</div>
</>
)}
{step === STEP.newEmail && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.newEmail')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>{t('common.account.changeEmail.content3')}</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.emailLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.emailPlaceholder')}
value={mail}
onChange={e => handleNewEmailValueChange(e.target.value)}
destructive={newEmailExited}
/>
{newEmailExited && (
<div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.existingEmail')}</div>
)}
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={!mail || newEmailExited || isCheckingEmail || !isValidEmail(mail)}
className='!w-full'
variant='primary'
onClick={sendCodeToNewEmail}
>
{t('common.account.changeEmail.sendVerifyCode')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
{step === STEP.verifyNew && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyNew')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.account.changeEmail.content4"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email: mail }}
/>
</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.codePlaceholder')}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={code.length !== 6}
className='!w-full'
variant='primary'
onClick={submitNewEmail}
>
{t('common.account.changeEmail.changeTo', { email: mail })}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
<span>{t('common.account.changeEmail.resendTip')}</span>
{time > 0 && (
<span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToNewEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
)}
</div>
</>
)}
</Modal>
)
}
export default EmailChangeModal

@ -1,9 +0,0 @@
.modal {
padding: 24px 32px !important;
width: 400px !important;
}
.bg {
background: linear-gradient(180deg, rgba(217, 45, 32, 0.05) 0%, rgba(217, 45, 32, 0.00) 24.02%), #F9FAFB;
}

@ -6,7 +6,6 @@ import {
} from '@remixicon/react'
import { useContext } from 'use-context-selector'
import DeleteAccount from '../delete-account'
import s from './index.module.css'
import AvatarWithEdit from './AvatarWithEdit'
import Collapse from '@/app/components/header/account-setting/collapse'
import type { IItem } from '@/app/components/header/account-setting/collapse'
@ -21,6 +20,7 @@ import { IS_CE_EDITION } from '@/config'
import Input from '@/app/components/base/input'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useGlobalPublicStore } from '@/context/global-public-context'
import EmailChangeModal from './email-change-modal'
import { validPassword } from '@/config'
const titleClassName = `
@ -47,6 +47,7 @@ export default function AccountPage() {
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [showUpdateEmail, setShowUpdateEmail] = useState(false)
const handleEditName = () => {
setEditNameModalVisible(true)
@ -122,10 +123,17 @@ export default function AccountPage() {
}
const renderAppItem = (item: IItem) => {
const { icon, icon_background, icon_type, icon_url } = item as any
return (
<div className='flex px-3 py-1'>
<div className='mr-3'>
<AppIcon size='tiny' />
<AppIcon
size='tiny'
iconType={icon_type}
icon={icon}
background={icon_background}
imageUrl={icon_url}
/>
</div>
<div className='system-sm-medium mt-[3px] text-text-secondary'>{item.name}</div>
</div>
@ -169,6 +177,11 @@ export default function AccountPage() {
<div className='system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled '>
<span className='pl-1'>{userProfile.email}</span>
</div>
{systemFeatures.enable_change_email && (
<div className='system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text' onClick={() => setShowUpdateEmail(true)}>
{t('common.operation.change')}
</div>
)}
</div>
</div>
{
@ -189,7 +202,7 @@ export default function AccountPage() {
{!!apps.length && (
<Collapse
title={`${t('common.account.showAppLength', { length: apps.length })}`}
items={apps.map(app => ({ key: app.id, name: app.name }))}
items={apps.map(app => ({ ...app, key: app.id, name: app.name }))}
renderItem={renderAppItem}
wrapperClassName='mt-2'
/>
@ -201,7 +214,7 @@ export default function AccountPage() {
<Modal
isShow
onClose={() => setEditNameModalVisible(false)}
className={s.modal}
className='!w-[420px] !p-6'
>
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{t('common.account.editName')}</div>
<div className={titleClassName}>{t('common.account.name')}</div>
@ -230,7 +243,7 @@ export default function AccountPage() {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
className={s.modal}
className='!w-[420px] !p-6'
>
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
{userProfile.is_password_set && (
@ -315,6 +328,13 @@ export default function AccountPage() {
/>
)
}
{showUpdateEmail && (
<EmailChangeModal
show={showUpdateEmail}
onClose={() => setShowUpdateEmail(false)}
email={userProfile.email}
/>
)}
</>
)
}

@ -18,11 +18,8 @@ import type {
import { noop } from 'lodash-es'
export type ChatWithHistoryContextValue = {
appInfoError?: any
appInfoLoading?: boolean
appMeta?: AppMeta
appData?: AppData
userCanAccess?: boolean
appMeta?: AppMeta | null
appData?: AppData | null
appParams?: ChatConfig
appChatListDataLoading?: boolean
currentConversationId: string
@ -62,7 +59,6 @@ export type ChatWithHistoryContextValue = {
}
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
userCanAccess: false,
currentConversationId: '',
appPrevChatTree: [],
pinnedConversationList: [],

@ -21,9 +21,6 @@ import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import {
delConversation,
fetchAppInfo,
fetchAppMeta,
fetchAppParams,
fetchChatList,
fetchConversations,
generationConversationName,
@ -43,8 +40,7 @@ import { useAppFavicon } from '@/hooks/use-app-favicon'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { noop } from 'lodash-es'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context'
function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = []
@ -74,13 +70,9 @@ function getFormattedChatList(messages: any[]) {
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
appId: installedAppInfo?.app.id || appInfo?.app_id,
isInstalledApp,
enabled: systemFeatures.webapp_auth.enabled,
})
const appInfo = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const appMeta = useWebAppStore(s => s.appMeta)
useAppFavicon({
enable: !installedAppInfo,
@ -107,6 +99,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
},
plan: 'basic',
custom_config: null,
} as AppData
}
@ -166,8 +159,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams(isInstalledApp, appId))
const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId))
const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100))
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
@ -485,9 +476,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [isInstalledApp, appId, t, notify])
return {
appInfoError,
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission),
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
isInstalledApp,
appId,
currentConversationId,

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import {
useCallback,
useEffect,
useState,
} from 'react'
@ -19,12 +18,10 @@ import ChatWrapper from './chat-wrapper'
import type { InstalledApp } from '@/models/explore'
import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { checkOrSetAccessToken, removeAccessToken } from '@/app/components/share/utils'
import { checkOrSetAccessToken } from '@/app/components/share/utils'
import AppUnavailable from '@/app/components/base/app-unavailable'
import cn from '@/utils/classnames'
import useDocumentTitle from '@/hooks/use-document-title'
import { useTranslation } from 'react-i18next'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
type ChatWithHistoryProps = {
className?: string
@ -33,16 +30,12 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
className,
}) => {
const {
userCanAccess,
appInfoError,
appData,
appInfoLoading,
appChatListDataLoading,
chatShouldReloadKey,
isMobile,
themeBuilder,
sidebarCollapseState,
isInstalledApp,
} = useChatWithHistoryContext()
const isSidebarCollapsed = sidebarCollapseState
const customConfig = appData?.custom_config
@ -56,41 +49,6 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
useDocumentTitle(site?.title || 'Chat')
const { t } = useTranslation()
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
params.set('redirect_url', pathname)
return `/webapp-signin?${params.toString()}`
}, [searchParams, pathname])
const backToHome = useCallback(() => {
removeAccessToken()
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router])
if (appInfoLoading) {
return (
<Loading type='app' />
)
}
if (!userCanAccess) {
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
{!isInstalledApp && <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>}
</div>
}
if (appInfoError) {
return (
<AppUnavailable />
)
}
return (
<div className={cn(
'flex h-full bg-background-default-burn',
@ -148,9 +106,6 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
const themeBuilder = useThemeContext()
const {
appInfoError,
appInfoLoading,
userCanAccess,
appData,
appParams,
appMeta,
@ -191,10 +146,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
return (
<ChatWithHistoryContext.Provider value={{
appInfoError,
appInfoLoading,
appData,
userCanAccess,
appParams,
appMeta,
appChatListDataLoading,

@ -1,10 +1,7 @@
'use client'
import {
useCallback,
useEffect,
useState,
} from 'react'
import { useAsyncEffect } from 'ahooks'
import { useTranslation } from 'react-i18next'
import {
EmbeddedChatbotContext,
@ -14,8 +11,6 @@ import { useEmbeddedChatbot } from './hooks'
import { isDify } from './utils'
import { useThemeContext } from './theme/theme-context'
import { CssTransform } from './theme/utils'
import { checkOrSetAccessToken, removeAccessToken } from '@/app/components/share/utils'
import AppUnavailable from '@/app/components/base/app-unavailable'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Loading from '@/app/components/base/loading'
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
@ -25,21 +20,16 @@ import DifyLogo from '@/app/components/base/logo/dify-logo'
import cn from '@/utils/classnames'
import useDocumentTitle from '@/hooks/use-document-title'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
const Chatbot = () => {
const {
userCanAccess,
isMobile,
allowResetChat,
appInfoError,
appInfoLoading,
appData,
appChatListDataLoading,
chatShouldReloadKey,
handleNewConversation,
themeBuilder,
isInstalledApp,
} = useEmbeddedChatbotContext()
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
@ -55,58 +45,6 @@ const Chatbot = () => {
useDocumentTitle(site?.title || 'Chat')
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
params.set('redirect_url', pathname)
return `/webapp-signin?${params.toString()}`
}, [searchParams, pathname])
const backToHome = useCallback(() => {
removeAccessToken()
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router])
if (appInfoLoading) {
return (
<>
{!isMobile && <Loading type='app' />}
{isMobile && (
<div className={cn('relative')}>
<div className={cn('flex h-[calc(100vh_-_60px)] flex-col rounded-2xl border-[0.5px] border-components-panel-border shadow-xs')}>
<Loading type='app' />
</div>
</div>
)}
</>
)
}
if (!userCanAccess) {
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
{!isInstalledApp && <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>}
</div>
}
if (appInfoError) {
return (
<>
{!isMobile && <AppUnavailable />}
{isMobile && (
<div className={cn('relative')}>
<div className={cn('flex h-[calc(100vh_-_60px)] flex-col rounded-2xl border-[0.5px] border-components-panel-border shadow-xs')}>
<AppUnavailable />
</div>
</div>
)}
</>
)
}
return (
<div className='relative'>
<div
@ -162,8 +100,6 @@ const EmbeddedChatbotWrapper = () => {
const themeBuilder = useThemeContext()
const {
appInfoError,
appInfoLoading,
appData,
userCanAccess,
appParams,
@ -200,8 +136,6 @@ const EmbeddedChatbotWrapper = () => {
return <EmbeddedChatbotContext.Provider value={{
userCanAccess,
appInfoError,
appInfoLoading,
appData,
appParams,
appMeta,
@ -241,34 +175,6 @@ const EmbeddedChatbotWrapper = () => {
}
const EmbeddedChatbot = () => {
const [initialized, setInitialized] = useState(false)
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false)
useAsyncEffect(async () => {
if (!initialized) {
try {
await checkOrSetAccessToken()
}
catch (e: any) {
if (e.status === 404) {
setAppUnavailable(true)
}
else {
setIsUnknownReason(true)
setAppUnavailable(true)
}
}
setInitialized(true)
}
}, [])
if (!initialized)
return null
if (appUnavailable)
return <AppUnavailable isUnknownReason={isUnknownReason} />
return <EmbeddedChatbotWrapper />
}

@ -49,6 +49,16 @@ export type ChatConfig = Omit<ModelConfig, 'model'> & {
questionEditEnable?: boolean
supportFeedback?: boolean
supportCitationHitInfo?: boolean
system_parameters: {
audio_file_size_limit: number
file_size_limit: number
image_file_size_limit: number
video_file_size_limit: number
workflow_file_upload_limit: number
}
more_like_this: {
enabled: boolean
}
}
export type WorkflowProcess = {

@ -99,7 +99,8 @@ export type CurrentPlanInfoBackend = {
workspace_members: {
size: number
limit: number
}
},
is_allow_transfer_workspace: boolean
}
export type SubscriptionItem = {

@ -22,6 +22,7 @@ const Explore: FC<IExploreProps> = ({
const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext()
const [hasEditPermission, setHasEditPermission] = useState(false)
const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
const [isFetchingInstalledApps, setIsFetchingInstalledApps] = useState(false)
const { t } = useTranslation()
useDocumentTitle(t('common.menus.explore'))
@ -51,6 +52,8 @@ const Explore: FC<IExploreProps> = ({
hasEditPermission,
installedApps,
setInstalledApps,
isFetchingInstalledApps,
setIsFetchingInstalledApps,
}
}
>

@ -1,11 +1,17 @@
'use client'
import type { FC } from 'react'
import { useEffect } from 'react'
import React from 'react'
import { useContext } from 'use-context-selector'
import ExploreContext from '@/context/explore-context'
import TextGenerationApp from '@/app/components/share/text-generation'
import Loading from '@/app/components/base/loading'
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
import { useWebAppStore } from '@/context/web-app-context'
import AppUnavailable from '../../base/app-unavailable'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
import type { AppData } from '@/models/share'
export type IInstalledAppProps = {
id: string
@ -14,26 +20,95 @@ export type IInstalledAppProps = {
const InstalledApp: FC<IInstalledAppProps> = ({
id,
}) => {
const { installedApps } = useContext(ExploreContext)
const { installedApps, isFetchingInstalledApps } = useContext(ExploreContext)
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
const installedApp = installedApps.find(item => item.id === id)
const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode)
const updateAppParams = useWebAppStore(s => s.updateAppParams)
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
const { isFetching: isFetchingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null)
const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null)
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: installedApp?.app.id, isInstalledApp: true })
useEffect(() => {
if (!installedApp) {
updateAppInfo(null)
}
else {
const { id, app } = installedApp
updateAppInfo({
app_id: id,
site: {
title: app.name,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
icon_url: app.icon_url,
prompt_public: false,
copyright: '',
show_workflow_steps: true,
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
},
plan: 'basic',
custom_config: null,
} as AppData)
}
if (appParams)
updateAppParams(appParams)
if (appMeta)
updateWebAppMeta(appMeta)
if (webAppAccessMode)
updateWebAppAccessMode(webAppAccessMode.accessMode)
updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result))
}, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode])
if (appParamsError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appParamsError.message} />
</div>
}
if (appMetaError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appMetaError.message} />
</div>
}
if (useCanAccessAppError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={useCanAccessAppError.message} />
</div>
}
if (webAppAccessModeError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={webAppAccessModeError.message} />
</div>
}
if (userCanAccessApp && !userCanAccessApp.result) {
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
</div>
}
if (isFetchingAppParams || isFetchingAppMeta || isFetchingWebAppAccessMode || isFetchingInstalledApps) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
}
if (!installedApp) {
return (
<div className='flex h-full items-center'>
<Loading type='area' />
</div>
)
return <div className='flex h-full items-center justify-center'>
<AppUnavailable code={404} isUnknownReason />
</div>
}
return (
<div className='h-full bg-background-default py-2 pl-0 pr-2 sm:p-2'>
{installedApp.app.mode !== 'completion' && installedApp.app.mode !== 'workflow' && (
{installedApp?.app.mode !== 'completion' && installedApp?.app.mode !== 'workflow' && (
<ChatWithHistory installedAppInfo={installedApp} className='overflow-hidden rounded-2xl shadow-md' />
)}
{installedApp.app.mode === 'completion' && (
{installedApp?.app.mode === 'completion' && (
<TextGenerationApp isInstalledApp installedAppInfo={installedApp} />
)}
{installedApp.app.mode === 'workflow' && (
{installedApp?.app.mode === 'workflow' && (
<TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp} />
)}
</div>

@ -8,11 +8,11 @@ import Link from 'next/link'
import Toast from '../../base/toast'
import Item from './app-nav-item'
import cn from '@/utils/classnames'
import { fetchInstalledAppList as doFetchInstalledAppList, uninstallApp, updatePinStatus } from '@/service/explore'
import ExploreContext from '@/context/explore-context'
import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
const SelectedDiscoveryIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="current" xmlns="http://www.w3.org/2000/svg">
@ -50,16 +50,14 @@ const SideBar: FC<IExploreSideBarProps> = ({
const lastSegment = segments.slice(-1)[0]
const isDiscoverySelected = lastSegment === 'apps'
const isChatSelected = lastSegment === 'chat'
const { installedApps, setInstalledApps } = useContext(ExploreContext)
const { installedApps, setInstalledApps, setIsFetchingInstalledApps } = useContext(ExploreContext)
const { isFetching: isFetchingInstalledApps, data: ret, refetch: fetchInstalledAppList } = useGetInstalledApps()
const { mutateAsync: uninstallApp } = useUninstallApp()
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const fetchInstalledAppList = async () => {
const { installed_apps }: any = await doFetchInstalledAppList()
setInstalledApps(installed_apps)
}
const [showConfirm, setShowConfirm] = useState(false)
const [currId, setCurrId] = useState('')
const handleDelete = async () => {
@ -70,25 +68,31 @@ const SideBar: FC<IExploreSideBarProps> = ({
type: 'success',
message: t('common.api.remove'),
})
fetchInstalledAppList()
}
const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
await updatePinStatus(id, isPinned)
await updatePinStatus({ appId: id, isPinned })
Toast.notify({
type: 'success',
message: t('common.api.success'),
})
fetchInstalledAppList()
}
useEffect(() => {
fetchInstalledAppList()
}, [])
const installed_apps = (ret as any)?.installed_apps
if (installed_apps && installed_apps.length > 0)
setInstalledApps(installed_apps)
else
setInstalledApps([])
}, [ret, setInstalledApps])
useEffect(() => {
setIsFetchingInstalledApps(isFetchingInstalledApps)
}, [isFetchingInstalledApps, setIsFetchingInstalledApps])
useEffect(() => {
fetchInstalledAppList()
}, [controlUpdateInstalledApps])
}, [controlUpdateInstalledApps, fetchInstalledAppList])
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
return (

@ -10,7 +10,9 @@ import { useTranslation } from 'react-i18next'
import InviteModal from './invite-modal'
import InvitedModal from './invited-modal'
import EditWorkspaceModal from './edit-workspace-modal'
import TransferOwnershipModal from './transfer-ownership-modal'
import Operation from './operation'
import TransferOwnership from './operation/transfer-ownership'
import { fetchMembers } from '@/service/common'
import I18n from '@/context/i18n'
import { useAppContext } from '@/context/app-context'
@ -52,10 +54,11 @@ const MembersPage = () => {
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
const accounts = data?.accounts || []
const { plan, enableBilling } = useProviderContext()
const { plan, enableBilling, isAllowTransferWorkspace } = useProviderContext()
const isNotUnlimitedMemberPlan = enableBilling && plan.type !== Plan.team && plan.type !== Plan.enterprise
const isMemberFull = enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers
const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false)
const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false)
return (
<>
@ -132,11 +135,18 @@ const MembersPage = () => {
</div>
<div className='system-sm-regular flex w-[104px] shrink-0 items-center py-2 text-text-secondary'>{dayjs(Number((account.last_active_at || account.created_at)) * 1000).locale(locale === 'zh-Hans' ? 'zh-cn' : 'en').fromNow()}</div>
<div className='flex w-[96px] shrink-0 items-center'>
{
isCurrentWorkspaceOwner && account.role !== 'owner'
? <Operation member={account} operatorRole={currentWorkspace.role} onOperate={mutate} />
: <div className='system-sm-regular px-3 text-text-secondary'>{RoleMap[account.role] || RoleMap.normal}</div>
}
{isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && (
<TransferOwnership onOperate={() => setShowTransferOwnershipModal(true)}></TransferOwnership>
)}
{isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && (
<div className='system-sm-regular px-3 text-text-secondary'>{RoleMap[account.role] || RoleMap.normal}</div>
)}
{isCurrentWorkspaceOwner && account.role !== 'owner' && (
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={mutate} />
)}
{!isCurrentWorkspaceOwner && (
<div className='system-sm-regular px-3 text-text-secondary'>{RoleMap[account.role] || RoleMap.normal}</div>
)}
</div>
</div>
))
@ -172,6 +182,12 @@ const MembersPage = () => {
/>
)
}
{showTransferOwnershipModal && (
<TransferOwnershipModal
show={showTransferOwnershipModal}
onClose={() => setShowTransferOwnershipModal(false)}
/>
)}
</>
)
}

@ -0,0 +1,54 @@
'use client'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import cn from '@/utils/classnames'
type Props = {
onOperate: () => void
}
const TransferOwnership = ({ onOperate }: Props) => {
const { t } = useTranslation()
return (
<Menu as="div" className="relative h-full w-full">
{
({ open }) => (
<>
<MenuButton className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{t('common.members.owner')}
<RiArrowDownSLine className={cn('h-4 w-4 group-hover:block', open ? 'block' : 'hidden')} />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
className={cn('absolute right-0 top-[52px] z-10 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}
>
<div className="p-1">
<MenuItem>
<div className='flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover' onClick={onOperate}>
<div className='system-md-regular whitespace-nowrap text-text-secondary'>{t('common.members.transferOwnership')}</div>
</div>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
)
}
export default TransferOwnership

@ -0,0 +1,253 @@
import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiCloseLine } from '@remixicon/react'
import { useAppContext } from '@/context/app-context'
import { ToastContext } from '@/app/components/base/toast'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import MemberSelector from './member-selector'
import {
ownershipTransfer,
sendOwnerEmail,
verifyOwnerEmail,
} from '@/service/common'
import { noop } from 'lodash-es'
type Props = {
show: boolean
onClose: () => void
}
enum STEP {
start = 'start',
verify = 'verify',
transfer = 'transfer',
}
const TransferOwnershipModal = ({ onClose, show }: Props) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { currentWorkspace, userProfile } = useAppContext()
const [step, setStep] = useState<STEP>(STEP.start)
const [code, setCode] = useState<string>('')
const [time, setTime] = useState<number>(0)
const [stepToken, setStepToken] = useState<string>('')
const [newOwner, setNewOwner] = useState<string>('')
const [isTransfer, setIsTransfer] = useState<boolean>(false)
const startCount = () => {
setTime(60)
const timer = setInterval(() => {
setTime((prev) => {
if (prev <= 0) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
}
const sendEmail = async () => {
try {
const res = await sendOwnerEmail({})
startCount()
if (res.data)
setStepToken(res.data)
}
catch (error) {
notify({
type: 'error',
message: `Error sending verification code: ${error ? (error as any).message : ''}`,
})
}
}
const verifyEmailAddress = async (code: string, token: string, callback?: () => void) => {
try {
const res = await verifyOwnerEmail({
code,
token,
})
if (res.is_valid) {
setStepToken(res.token)
callback?.()
}
else {
notify({
type: 'error',
message: 'Verifying email failed',
})
}
}
catch (error) {
notify({
type: 'error',
message: `Error verifying email: ${error ? (error as any).message : ''}`,
})
}
}
const sendCodeToOriginEmail = async () => {
await sendEmail()
setStep(STEP.verify)
}
const handleVerifyOriginEmail = async () => {
await verifyEmailAddress(code, stepToken, () => setStep(STEP.transfer))
setCode('')
}
const handleTransfer = async () => {
setIsTransfer(true)
try {
await ownershipTransfer(
newOwner,
{
token: stepToken,
},
)
globalThis.location.reload()
}
catch (error) {
notify({
type: 'error',
message: `Error ownership transfer: ${error ? (error as any).message : ''}`,
})
}
finally {
setIsTransfer(false)
}
}
return (
<Modal
isShow={show}
onClose={noop}
className='!w-[420px] !p-6'
>
<div className='absolute right-5 top-5 cursor-pointer p-1.5' onClick={onClose}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
{step === STEP.start && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.members.transferModal.title')}</div>
<div className='space-y-1 pb-2 pt-1'>
<div className='body-md-medium text-text-destructive'>{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '') })}</div>
<div className='body-md-regular text-text-secondary'>{t('common.members.transferModal.warningTip')}</div>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.members.transferModal.sendTip"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email: userProfile.email }}
/>
</div>
</div>
<div className='pt-3'></div>
<div className='space-y-2'>
<Button
className='!w-full'
variant='primary'
onClick={sendCodeToOriginEmail}
>
{t('common.members.transferModal.sendVerifyCode')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
{step === STEP.verify && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.members.transferModal.verifyEmail')}</div>
<div className='pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.members.transferModal.verifyContent"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email: userProfile.email }}
/>
</div>
<div className='body-md-regular text-text-secondary'>{t('common.members.transferModal.verifyContent2')}</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.members.transferModal.codeLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.members.transferModal.codePlaceholder')}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={code.length !== 6}
className='!w-full'
variant='primary'
onClick={handleVerifyOriginEmail}
>
{t('common.members.transferModal.continue')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
<span>{t('common.members.transferModal.resendTip')}</span>
{time > 0 && (
<span>{t('common.members.transferModal.resendCount', { count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.members.transferModal.resend')}</span>
)}
</div>
</>
)}
{step === STEP.transfer && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.members.transferModal.title')}</div>
<div className='space-y-1 pb-2 pt-1'>
<div className='body-md-medium text-text-destructive'>{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '') })}</div>
<div className='body-md-regular text-text-secondary'>{t('common.members.transferModal.warningTip')}</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.members.transferModal.transferLabel')}</div>
<MemberSelector
exclude={[userProfile.id]}
value={newOwner}
onSelect={setNewOwner}
/>
</div>
<div className='mt-4 space-y-2'>
<Button
disabled={!newOwner || isTransfer}
className='!w-full'
variant='warning'
onClick={handleTransfer}
>
{t('common.members.transferModal.transfer')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
</Modal>
)
}
export default TransferOwnershipModal

@ -0,0 +1,112 @@
'use client'
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Avatar from '@/app/components/base/avatar'
import Input from '@/app/components/base/input'
import { fetchMembers } from '@/service/common'
import cn from '@/utils/classnames'
type Props = {
value?: any
onSelect: (value: any) => void
exclude?: string[]
}
const MemberSelector: FC<Props> = ({
value,
onSelect,
exclude = [],
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const { data } = useSWR(
{
url: '/workspaces/current/members',
params: {},
},
fetchMembers,
)
const currentValue = useMemo(() => {
if (!data?.accounts) return null
const accounts = data.accounts || []
if (!value) return null
return accounts.find(account => account.id === value)
}, [data, value])
const filteredList = useMemo(() => {
if (!data?.accounts) return []
const accounts = data.accounts
if (!searchValue) return accounts.filter(account => !exclude.includes(account.id))
return accounts.filter((account) => {
const name = account.name || ''
const email = account.email || ''
return name.toLowerCase().includes(searchValue.toLowerCase())
|| email.toLowerCase().includes(searchValue.toLowerCase())
}).filter(account => !exclude.includes(account.id))
}, [data, searchValue, exclude])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom'
offset={4}
>
<PortalToFollowElemTrigger
className='w-full'
onClick={() => setOpen(v => !v)}
>
<div className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}>
{!currentValue && (
<div className='system-sm-regular grow p-1 text-components-input-text-placeholder'>{t('common.members.transferModal.transferPlaceholder')}</div>
)}
{currentValue && (
<>
<Avatar avatar={currentValue.avatar_url} size={24} name={currentValue.name} />
<div className='system-sm-medium grow truncate text-text-secondary'>{currentValue.name}</div>
<div className='system-xs-regular text-text-quaternary'>{currentValue.email}</div>
</>
)}
<RiArrowDownSLine className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-2 pb-1'>
<Input
showLeftIcon
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
/>
</div>
<div className='p-1'>
{filteredList.map(account => (
<div
key={account.id}
className='flex cursor-pointer items-center gap-2 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover'
onClick={() => {
onSelect(account.id)
setOpen(false)
}}
>
<Avatar avatar={account.avatar_url} size={24} name={account.name} />
<div className='system-sm-medium grow truncate text-text-secondary'>{account.name}</div>
<div className='system-xs-regular text-text-quaternary'>{account.email}</div>
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default MemberSelector

@ -7,16 +7,14 @@ import {
RiErrorWarningFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useSearchParams } from 'next/navigation'
import TabHeader from '../../base/tab-header'
import { checkOrSetAccessToken, removeAccessToken } from '../utils'
import MenuDropdown from './menu-dropdown'
import RunBatch from './run-batch'
import ResDownload from './run-batch/res-download'
import AppUnavailable from '../../base/app-unavailable'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import RunOnce from '@/app/components/share/text-generation/run-once'
import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share'
import { fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
import type { SiteInfo } from '@/models/share'
import type {
MoreLikeThisConfig,
@ -39,10 +37,10 @@ import { Resolution, TransferMethod } from '@/types/app'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import cn from '@/utils/classnames'
import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control'
import { AccessMode } from '@/models/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useWebAppStore } from '@/context/web-app-context'
const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group.
enum TaskStatus {
@ -83,9 +81,6 @@ const TextGeneration: FC<IMainProps> = ({
const mode = searchParams.get('mode') || 'create'
const [currentTab, setCurrentTab] = useState<string>(['create', 'batch'].includes(mode) ? mode : 'create')
const router = useRouter()
const pathname = usePathname()
// Notice this situation isCallBatchAPI but not in batch tab
const [isCallBatchAPI, setIsCallBatchAPI] = useState(false)
const isInBatchTab = currentTab === 'batch'
@ -103,30 +98,19 @@ const TextGeneration: FC<IMainProps> = ({
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({
appId,
isInstalledApp,
enabled: systemFeatures.webapp_auth.enabled,
})
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
appId,
isInstalledApp,
enabled: systemFeatures.webapp_auth.enabled,
})
// save message
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
const fetchSavedMessage = async () => {
const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id)
const fetchSavedMessage = useCallback(async () => {
const res: any = await doFetchSavedMessage(isInstalledApp, appId)
setSavedMessages(res.data)
}
}, [isInstalledApp, appId])
const handleSaveMessage = async (messageId: string) => {
await saveMessage(messageId, isInstalledApp, installedAppInfo?.id)
await saveMessage(messageId, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.saved') })
fetchSavedMessage()
}
const handleRemoveSavedMessage = async (messageId: string) => {
await removeMessage(messageId, isInstalledApp, installedAppInfo?.id)
await removeMessage(messageId, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.remove') })
fetchSavedMessage()
}
@ -375,34 +359,14 @@ const TextGeneration: FC<IMainProps> = ({
}
}
const fetchInitData = async () => {
if (!isInstalledApp)
await checkOrSetAccessToken()
return Promise.all([
isInstalledApp
? {
app_id: installedAppInfo?.id,
site: {
title: installedAppInfo?.app.name,
prompt_public: false,
copyright: '',
icon: installedAppInfo?.app.icon,
icon_background: installedAppInfo?.app.icon_background,
},
plan: 'basic',
}
: fetchAppInfo(),
fetchAppParams(isInstalledApp, installedAppInfo?.id),
!isWorkflow
? fetchSavedMessage()
: {},
])
}
const appData = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const accessMode = useWebAppStore(s => s.webAppAccessMode)
useEffect(() => {
(async () => {
const [appData, appParams]: any = await fetchInitData()
if (!appData || !appParams)
return
!isWorkflow && fetchSavedMessage()
const { app_id: appId, site: siteInfo, custom_config } = appData
setAppId(appId)
setSiteInfo(siteInfo as SiteInfo)
@ -413,11 +377,11 @@ const TextGeneration: FC<IMainProps> = ({
setVisionConfig({
// legacy of image upload compatible
...file_upload,
transfer_methods: file_upload.allowed_file_upload_methods || file_upload.allowed_upload_methods,
transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods,
// legacy of image upload compatible
image_file_size_limit: appParams?.system_parameters?.image_file_size_limit,
image_file_size_limit: appParams?.system_parameters.image_file_size_limit,
fileUploadConfig: appParams?.system_parameters,
})
} as any)
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
setPromptConfig({
prompt_template: '', // placeholder for future
@ -426,7 +390,7 @@ const TextGeneration: FC<IMainProps> = ({
setMoreLikeThisConfig(more_like_this)
setTextToSpeechConfig(text_to_speech)
})()
}, [])
}, [appData, appParams, fetchSavedMessage, isWorkflow])
// Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
useDocumentTitle(siteInfo?.title || t('share.generation.title'))
@ -528,32 +492,12 @@ const TextGeneration: FC<IMainProps> = ({
</div>
)
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
params.set('redirect_url', pathname)
return `/webapp-signin?${params.toString()}`
}, [searchParams, pathname])
const backToHome = useCallback(() => {
removeAccessToken()
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router])
if (!appId || !siteInfo || !promptConfig || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission))) {
if (!appId || !siteInfo || !promptConfig) {
return (
<div className='flex h-screen items-center'>
<Loading type='app' />
</div>)
}
if (systemFeatures.webapp_auth.enabled && !userCanAccessResult?.result) {
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
{!isInstalledApp && <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>}
</div>
}
return (
<div className={cn(
'bg-background-default-burn',
@ -578,7 +522,7 @@ const TextGeneration: FC<IMainProps> = ({
imageUrl={siteInfo.icon_url}
/>
<div className='system-md-semibold grow truncate text-text-secondary'>{siteInfo.title}</div>
<MenuDropdown hideLogout={isInstalledApp || appAccessMode?.accessMode === AccessMode.PUBLIC} data={siteInfo} />
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} data={siteInfo} />
</div>
{siteInfo.description && (
<div className='system-xs-regular text-text-tertiary'>{siteInfo.description}</div>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save