Compare commits

...

14 Commits

Author SHA1 Message Date
Yunlu Wen
780f969121 fix: fixed workflow as tool files field return empty problem (#28506)
Co-authored-by: kurokobo <kuro664@gmail.com>
original fix https://github.com/langgenius/dify/pull/27925
2025-11-21 17:35:38 +08:00
NFish
338e0f74b9 hide brand name in enterprise use (#27422) 2025-10-24 17:26:10 +08:00
NFish
cad6db5a1d fix: show 'Invalid email or password' error tip when web app login failed (#27034) 2025-10-21 11:03:43 +08:00
NFish
2e9b3b8d44 Fix/web app permission check (#26821) 2025-10-21 11:03:14 +08:00
GareArc
5a80f5158f fix: clear provider model credentials cache after updates in provider configuration 2025-10-20 20:01:55 -07:00
QuantumGhost
5a92e0feee fix(api): ensure JSON responses are properly serialized in ApiTool (#27097)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-10-19 18:56:25 +08:00
Yeuoly
4b5196f402 fix: ensure original response are maintained by yielding text messages in ApiTool (#23456) (#25973) 2025-10-19 10:20:08 +08:00
Xiyuan Chen
7b64569c8c Update email templates to improve clarity and consistency in messagin… (#26881) 2025-10-15 16:47:54 +08:00
Xiyuan Chen
6106207039 Fix/token exp when exchange (#26707) 2025-10-10 00:39:37 -07:00
Garfield Dai
3b4e9b64af delete end_user check (#26402)
Co-authored-by: Jyong <76649700+JohnJyong@users.noreply.github.com>
2025-09-29 16:21:29 +08:00
Garfield Dai
5073ce6e22 Fix/webapp remove code (#26436)
Co-authored-by: GareArc <chen4851@purdue.edu>
2025-09-29 15:58:41 +08:00
QuantumGhost
1277a57641 fix(api): fix internal server error caused by NULL environment_variables (#26125) 2025-09-23 18:21:16 +08:00
GareArc
d3ac5b1dd8 Refactor WorkflowService to handle missing default credentials gracefully 2025-09-19 00:27:35 -07:00
-LAN-
39a0b89b9a Fix: enforce editor-only access to chat message logs (#25936) 2025-09-18 22:01:56 +08:00
35 changed files with 561 additions and 148 deletions

View File

@@ -62,6 +62,9 @@ class ChatMessageListApi(Resource):
@account_initialization_required
@marshal_with(message_infinite_scroll_pagination_fields)
def get(self, app_model):
if not isinstance(current_user, Account) or not current_user.has_edit_permission:
raise Forbidden()
parser = reqparse.RequestParser()
parser.add_argument("conversation_id", required=True, type=uuid_value, location="args")
parser.add_argument("first_id", type=uuid_value, location="args")

View File

@@ -15,7 +15,6 @@ from libs.datetime_utils import naive_utc_now
from libs.login import current_user, login_required
from models import Account, App, InstalledApp, RecommendedApp
from services.account_service import TenantService
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
@@ -68,31 +67,26 @@ class InstalledAppsListApi(Resource):
# Pre-filter out apps without setting or with sso_verified
filtered_installed_apps = []
app_id_to_app_code = {}
for installed_app in installed_app_list:
app_id = installed_app["app"].id
webapp_setting = webapp_settings.get(app_id)
if not webapp_setting or webapp_setting.access_mode == "sso_verified":
continue
app_code = AppService.get_app_code_by_id(str(app_id))
app_id_to_app_code[app_id] = app_code
filtered_installed_apps.append(installed_app)
app_codes = list(app_id_to_app_code.values())
# Batch permission check
app_ids = [installed_app["app"].id for installed_app in filtered_installed_apps]
permissions = EnterpriseService.WebAppAuth.batch_is_user_allowed_to_access_webapps(
user_id=user_id,
app_codes=app_codes,
app_ids=app_ids,
)
# Keep only allowed apps
res = []
for installed_app in filtered_installed_apps:
app_id = installed_app["app"].id
app_code = app_id_to_app_code[app_id]
if permissions.get(app_code):
if permissions.get(app_id):
res.append(installed_app)
installed_app_list = res

View File

@@ -11,7 +11,6 @@ from controllers.console.wraps import account_initialization_required
from extensions.ext_database import db
from libs.login import login_required
from models import InstalledApp
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
@@ -57,10 +56,9 @@ def user_allowed_to_access_app(view: Callable[Concatenate[InstalledApp, P], R] |
feature = FeatureService.get_system_features()
if feature.webapp_auth.enabled:
app_id = installed_app.app_id
app_code = AppService.get_app_code_by_id(app_id)
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
user_id=str(current_user.id),
app_code=app_code,
app_id=app_id,
)
if not res:
raise AppAccessDeniedError()

View File

@@ -30,7 +30,6 @@ from extensions.ext_database import db
from fields.document_fields import document_fields, document_status_fields
from libs.login import current_user
from models.dataset import Dataset, Document, DocumentSegment
from models.model import EndUser
from services.dataset_service import DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig
from services.file_service import FileService
@@ -299,9 +298,6 @@ class DocumentAddByFileApi(DatasetApiResource):
if not file.filename:
raise FilenameNotExistsError
if not isinstance(current_user, EndUser):
raise ValueError("Invalid user account")
upload_file = FileService.upload_file(
filename=file.filename,
content=file.read(),
@@ -391,8 +387,6 @@ class DocumentUpdateByFileApi(DatasetApiResource):
raise FilenameNotExistsError
try:
if not isinstance(current_user, EndUser):
raise ValueError("Invalid user account")
upload_file = FileService.upload_file(
filename=file.filename,
content=file.read(),

View File

@@ -160,9 +160,8 @@ class AppWebAuthPermission(Resource):
args = parser.parse_args()
app_id = args["appId"]
app_code = AppService.get_app_code_by_id(app_id)
res = True
if WebAppAuthService.is_app_require_permission_check(app_id=app_id):
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_code)
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_id)
return {"result": res}

View File

@@ -12,6 +12,7 @@ from controllers.web.error import WebAppAuthRequiredError
from extensions.ext_database import db
from libs.passport import PassportService
from models.model import App, EndUser, Site
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
@@ -38,7 +39,7 @@ class PassportResource(Resource):
if app_code is None:
raise Unauthorized("X-App-Code header is missing.")
app_id = AppService.get_app_id_by_code(app_code)
# exchange token for enterprise logined web user
enterprise_user_decoded = decode_enterprise_webapp_user_id(web_app_access_token)
if enterprise_user_decoded:
@@ -48,7 +49,7 @@ class PassportResource(Resource):
)
if system_features.webapp_auth.enabled:
app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code)
app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id)
if not app_settings or not app_settings.access_mode == "public":
raise WebAppAuthRequiredError()
@@ -126,6 +127,8 @@ def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded:
end_user_id = enterprise_user_decoded.get("end_user_id")
session_id = enterprise_user_decoded.get("session_id")
user_auth_type = enterprise_user_decoded.get("auth_type")
exchanged_token_expires_unix = enterprise_user_decoded.get("exp")
if not user_auth_type:
raise Unauthorized("Missing auth_type in the token.")
@@ -169,8 +172,11 @@ def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded:
)
db.session.add(end_user)
db.session.commit()
exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)
exp = int(exp_dt.timestamp())
exp = int((datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)).timestamp())
if exchanged_token_expires_unix:
exp = int(exchanged_token_expires_unix)
payload = {
"iss": site.id,
"sub": "Web API Passport",

View File

@@ -13,6 +13,7 @@ from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequire
from extensions.ext_database import db
from libs.passport import PassportService
from models.model import App, EndUser, Site
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService, WebAppSettings
from services.feature_service import FeatureService
from services.webapp_auth_service import WebAppAuthService
@@ -37,7 +38,11 @@ def validate_jwt_token(view: Callable[Concatenate[App, EndUser, P], R] | None =
def decode_jwt_token():
system_features = FeatureService.get_system_features()
app_code = str(request.headers.get("X-App-Code"))
app_code = request.headers.get("X-App-Code")
if not app_code:
app_code = None
else:
app_code = str(app_code)
try:
auth_header = request.headers.get("Authorization")
if auth_header is None:
@@ -51,15 +56,30 @@ def decode_jwt_token():
if auth_scheme != "bearer":
raise Unauthorized("Invalid Authorization header format. Expected 'Bearer <api-key>' format.")
# Check for invalid token values
if tk in ["undefined", "null", "None", ""]:
raise Unauthorized("Invalid token provided.")
decoded = PassportService().verify(tk)
app_code = decoded.get("app_code")
# Preserve app_code from header if JWT token doesn't contain one
jwt_app_code = decoded.get("app_code")
if jwt_app_code:
app_code = jwt_app_code
app_id = decoded.get("app_id")
# Validate required fields from JWT token
if not app_id:
raise Unauthorized("Invalid token: missing app_id.")
if not app_code:
raise Unauthorized("Invalid token: missing app_code.")
with Session(db.engine, expire_on_commit=False) as session:
app_model = session.scalar(select(App).where(App.id == app_id))
site = session.scalar(select(Site).where(Site.code == app_code))
if not app_model:
raise NotFound()
if not app_code or not site:
if not site:
raise BadRequest("Site URL is no longer valid.")
if app_model.enable_site is False:
raise BadRequest("Site is disabled.")
@@ -72,7 +92,12 @@ def decode_jwt_token():
app_web_auth_enabled = False
webapp_settings = None
if system_features.webapp_auth.enabled:
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code)
if not app_code:
raise BadRequest("App code is required for webapp authentication.")
if app_code in ["undefined", "null", "None", ""]:
raise BadRequest("Invalid app code provided.")
app_id = AppService.get_app_id_by_code(app_code)
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
if not webapp_settings:
raise NotFound("Web app settings not found.")
app_web_auth_enabled = webapp_settings.access_mode != "public"
@@ -87,8 +112,11 @@ def decode_jwt_token():
if system_features.webapp_auth.enabled:
if not app_code:
raise Unauthorized("Please re-login to access the web app.")
if app_code in ["undefined", "null", "None", ""]:
raise Unauthorized("Invalid app code provided.")
app_id = AppService.get_app_id_by_code(app_code)
app_web_auth_enabled = (
EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=str(app_code)).access_mode != "public"
EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id).access_mode != "public"
)
if app_web_auth_enabled:
raise WebAppAuthRequiredError()
@@ -129,7 +157,10 @@ def _validate_user_accessibility(
raise WebAppAuthRequiredError("Web app settings not found.")
if WebAppAuthService.is_app_require_permission_check(access_mode=webapp_settings.access_mode):
if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_code=app_code):
if not app_code or app_code in ["undefined", "null", "None", ""]:
raise WebAppAuthAccessDeniedError("Invalid app code for permission check.")
app_id = AppService.get_app_id_by_code(app_code)
if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_id):
raise WebAppAuthAccessDeniedError()
auth_type = decoded.get("auth_type")

View File

@@ -1140,6 +1140,15 @@ class ProviderConfiguration(BaseModel):
raise ValueError("Can't add same credential")
provider_model_record.credential_id = credential_record.id
provider_model_record.updated_at = naive_utc_now()
# clear cache
provider_model_credentials_cache = ProviderCredentialsCache(
tenant_id=self.tenant_id,
identity_id=provider_model_record.id,
cache_type=ProviderCredentialsCacheType.MODEL,
)
provider_model_credentials_cache.delete()
session.add(provider_model_record)
session.commit()
@@ -1173,6 +1182,14 @@ class ProviderConfiguration(BaseModel):
session.add(provider_model_record)
session.commit()
# clear cache
provider_model_credentials_cache = ProviderCredentialsCache(
tenant_id=self.tenant_id,
identity_id=provider_model_record.id,
cache_type=ProviderCredentialsCacheType.MODEL,
)
provider_model_credentials_cache.delete()
def delete_custom_model(self, model_type: ModelType, model: str):
"""
Delete custom model.

View File

@@ -394,8 +394,14 @@ class ApiTool(Tool):
parsed_response = self.validate_and_parse_response(response)
# assemble invoke message based on response type
if parsed_response.is_json and isinstance(parsed_response.content, dict):
yield self.create_json_message(parsed_response.content)
if parsed_response.is_json:
if isinstance(parsed_response.content, dict):
yield self.create_json_message(parsed_response.content)
# The yield below must be preserved to keep backward compatibility.
#
# ref: https://github.com/langgenius/dify/pull/23456#issuecomment-3182413088
yield self.create_text_message(response.text)
else:
# Convert to string if needed and create text message
text_response = (

View File

@@ -318,7 +318,13 @@ class ToolNode(BaseNode):
json.append(message.message.json_object)
elif message.type == ToolInvokeMessage.MessageType.LINK:
assert isinstance(message.message, ToolInvokeMessage.TextMessage)
stream_text = f"Link: {message.message.text}\n"
# Check if this LINK message is a file link
file_obj = (message.meta or {}).get("file")
if isinstance(file_obj, File):
files.append(file_obj)
stream_text = f"File: {message.message.text}\n"
else:
stream_text = f"Link: {message.message.text}\n"
text += stream_text
yield RunStreamChunkEvent(chunk_content=stream_text, from_variable_selector=[node_id, "text"])
elif message.type == ToolInvokeMessage.MessageType.VARIABLE:

View File

@@ -354,7 +354,7 @@ class Workflow(Base):
if not tenant_id:
return []
environment_variables_dict: dict[str, Any] = json.loads(self._environment_variables)
environment_variables_dict: dict[str, Any] = json.loads(self._environment_variables or "{}")
results = [
variable_factory.build_environment_variable_from_mapping(v) for v in environment_variables_dict.values()
]

View File

@@ -46,17 +46,17 @@ class EnterpriseService:
class WebAppAuth:
@classmethod
def is_user_allowed_to_access_webapp(cls, user_id: str, app_code: str):
params = {"userId": user_id, "appCode": app_code}
def is_user_allowed_to_access_webapp(cls, user_id: str, app_id: str):
params = {"userId": user_id, "appId": app_id}
data = EnterpriseRequest.send_request("GET", "/webapp/permission", params=params)
return data.get("result", False)
@classmethod
def batch_is_user_allowed_to_access_webapps(cls, user_id: str, app_codes: list[str]):
if not app_codes:
def batch_is_user_allowed_to_access_webapps(cls, user_id: str, app_ids: list[str]):
if not app_ids:
return {}
body = {"userId": user_id, "appCodes": app_codes}
body = {"userId": user_id, "appIds": app_ids}
data = EnterpriseRequest.send_request("POST", "/webapp/permission/batch", json=body)
if not data:
raise ValueError("No data found.")
@@ -92,16 +92,6 @@ class EnterpriseService:
return ret
@classmethod
def get_app_access_mode_by_code(cls, app_code: str) -> WebAppSettings:
if not app_code:
raise ValueError("app_code must be provided.")
params = {"appCode": app_code}
data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/code", params=params)
if not data:
raise ValueError("No data found.")
return WebAppSettings(**data)
@classmethod
def update_app_access_mode(cls, app_id: str, access_mode: str):
if not app_id:

View File

@@ -172,7 +172,8 @@ class WebAppAuthService:
return WebAppAuthType.EXTERNAL
if app_code:
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code)
app_id = AppService.get_app_id_by_code(app_code)
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
return cls.get_app_auth_type(access_mode=webapp_settings.access_mode)
raise ValueError("Could not determine app authentication type.")

View File

@@ -452,7 +452,8 @@ class WorkflowService:
)
if not default_provider:
raise ValueError("No default credential found")
# plugin does not require credentials, skip
return
# Check credential policy compliance using the default credential ID
from core.helper.credential_utils import check_credential_policy_compliance

View File

@@ -42,7 +42,8 @@
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
line-height: 120%;
/* 28.8px */
}
.description {
@@ -51,7 +52,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
@@ -96,7 +98,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
</style>
@@ -107,7 +110,7 @@
<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="content1">You're updating the email address linked to your 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>
@@ -118,5 +121,4 @@
</div>
</body>
</html>
</html>

View File

@@ -42,7 +42,8 @@
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
line-height: 120%;
/* 28.8px */
}
.description {
@@ -51,7 +52,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
@@ -96,7 +98,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
</style>
@@ -107,7 +110,7 @@
<div class="header"></div>
<p class="title">确认您的邮箱地址变更</p>
<div class="description">
<p class="content1">您正在更新与您的 Dify 账户关联的邮箱地址。</p>
<p class="content1">您正在更新与您的账户关联的邮箱地址。</p>
<p class="content2">为了确认此操作,请使用以下验证码。</p>
<p class="content3">此验证码仅在接下来的5分钟内有效</p>
</div>
@@ -118,5 +121,4 @@
</div>
</body>
</html>
</html>

View File

@@ -42,7 +42,8 @@
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
line-height: 120%;
/* 28.8px */
}
.description {
@@ -51,7 +52,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
@@ -96,7 +98,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
</style>
@@ -107,7 +110,7 @@
<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="content1">We received a request to change the email address associated with your 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>
@@ -118,5 +121,4 @@
</div>
</body>
</html>
</html>

View File

@@ -42,7 +42,8 @@
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
line-height: 120%;
/* 28.8px */
}
.description {
@@ -51,7 +52,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
@@ -96,7 +98,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
</style>
@@ -107,7 +110,7 @@
<div class="header"></div>
<p class="title">验证您的邮箱变更请求</p>
<div class="description">
<p class="content1">我们收到了一个变更您 Dify 账户关联邮箱地址的请求。</p>
<p class="content1">我们收到了一个变更您账户关联邮箱地址的请求。</p>
<p class="content3">此验证码仅在接下来的5分钟内有效</p>
</div>
<div class="code-content">
@@ -117,5 +120,4 @@
</div>
</body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
@@ -10,6 +11,7 @@
margin: 0;
padding: 0;
}
.container {
width: 504px;
min-height: 444px;
@@ -30,6 +32,7 @@
max-width: 63px;
height: auto;
}
.button {
display: block;
padding: 8px 12px;
@@ -45,49 +48,56 @@
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
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% */
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="header"></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">{{ 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 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>
</html>

View File

@@ -42,7 +42,8 @@
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
line-height: 120%;
/* 28.8px */
}
.description {
@@ -51,7 +52,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
@@ -80,10 +82,9 @@
<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>
<p class="content3">If you have any questions, please contact support.</p>
</div>
</div>
</body>
</html>
</html>

View File

@@ -42,7 +42,8 @@
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
line-height: 120%;
/* 28.8px */
}
.description {
@@ -51,7 +52,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
@@ -80,10 +82,9 @@
<div class="description">
<p class="content1">您已被分配为工作空间“{{WorkspaceName}}”的新所有者。</p>
<p class="content2">作为新所有者,您现在对该工作空间拥有完全的管理权限。</p>
<p class="content3">如果您有任何问题,请联系support@dify.ai</p>
<p class="content3">如果您有任何问题,请联系支持团队</p>
</div>
</div>
</body>
</html>
</html>

View File

@@ -42,7 +42,8 @@
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
line-height: 120%;
/* 28.8px */
}
.description {
@@ -51,7 +52,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
@@ -97,7 +99,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
</style>
@@ -108,12 +111,14 @@
<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>
<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 immediately.</p>
</div>
</div>
</body>
</html>
</html>

View File

@@ -42,7 +42,8 @@
font-family: Inter;
font-style: normal;
font-weight: 600;
line-height: 120%; /* 28.8px */
line-height: 120%;
/* 28.8px */
}
.description {
@@ -51,7 +52,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
@@ -97,7 +99,8 @@
font-family: Inter;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
line-height: 20px;
/* 142.857% */
letter-spacing: -0.07px;
}
</style>
@@ -110,10 +113,9 @@
<div class="description">
<p class="content1">您已成功将工作空间“{{WorkspaceName}}”的所有权转移给{{NewOwnerEmail}}。</p>
<p class="content2">您不再拥有此工作空间的拥有者权限。您的访问级别已更改为管理员。</p>
<p class="content3">如果您没有发起此转移或对此变更有任何疑问,请立即联系support@dify.ai</p>
<p class="content3">如果您没有发起此转移或对此变更有任何疑问,请立即联系支持团队</p>
</div>
</div>
</body>
</html>
</html>

View File

@@ -1,12 +1,14 @@
"""Integration tests for ChatMessageApi permission verification."""
import uuid
from types import SimpleNamespace
from unittest import mock
import pytest
from flask.testing import FlaskClient
from controllers.console.app import completion as completion_api
from controllers.console.app import message as message_api
from controllers.console.app import wraps
from libs.datetime_utils import naive_utc_now
from models import Account, App, Tenant
@@ -99,3 +101,106 @@ class TestChatMessageApiPermissions:
)
assert response.status_code == status
@pytest.mark.parametrize(
("role", "status"),
[
(TenantAccountRole.OWNER, 200),
(TenantAccountRole.ADMIN, 200),
(TenantAccountRole.EDITOR, 200),
(TenantAccountRole.NORMAL, 403),
(TenantAccountRole.DATASET_OPERATOR, 403),
],
)
def test_get_requires_edit_permission(
self,
test_client: FlaskClient,
auth_header,
monkeypatch,
mock_app_model,
mock_account,
role: TenantAccountRole,
status: int,
):
"""Ensure GET chat-messages endpoint enforces edit permissions."""
mock_load_app_model = mock.Mock(return_value=mock_app_model)
monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model)
conversation_id = uuid.uuid4()
created_at = naive_utc_now()
mock_conversation = SimpleNamespace(id=str(conversation_id), app_id=str(mock_app_model.id))
mock_message = SimpleNamespace(
id=str(uuid.uuid4()),
conversation_id=str(conversation_id),
inputs=[],
query="hello",
message=[{"text": "hello"}],
message_tokens=0,
re_sign_file_url_answer="",
answer_tokens=0,
provider_response_latency=0.0,
from_source="console",
from_end_user_id=None,
from_account_id=mock_account.id,
feedbacks=[],
workflow_run_id=None,
annotation=None,
annotation_hit_history=None,
created_at=created_at,
agent_thoughts=[],
message_files=[],
message_metadata_dict={},
status="success",
error="",
parent_message_id=None,
)
class MockQuery:
def __init__(self, model):
self.model = model
def where(self, *args, **kwargs):
return self
def first(self):
if getattr(self.model, "__name__", "") == "Conversation":
return mock_conversation
return None
def order_by(self, *args, **kwargs):
return self
def limit(self, *_):
return self
def all(self):
if getattr(self.model, "__name__", "") == "Message":
return [mock_message]
return []
mock_session = mock.Mock()
mock_session.query.side_effect = MockQuery
mock_session.scalar.return_value = False
monkeypatch.setattr(message_api, "db", SimpleNamespace(session=mock_session))
monkeypatch.setattr(message_api, "current_user", mock_account)
class DummyPagination:
def __init__(self, data, limit, has_more):
self.data = data
self.limit = limit
self.has_more = has_more
monkeypatch.setattr(message_api, "InfiniteScrollPagination", DummyPagination)
mock_account.role = role
response = test_client.get(
f"/console/api/apps/{mock_app_model.id}/chat-messages",
headers=auth_header,
query_string={"conversation_id": str(conversation_id)},
)
assert response.status_code == status

View File

@@ -35,9 +35,7 @@ class TestWebAppAuthService:
mock_enterprise_service.WebAppAuth.get_app_access_mode_by_id.return_value = type(
"MockWebAppAuth", (), {"access_mode": "private"}
)()
mock_enterprise_service.WebAppAuth.get_app_access_mode_by_code.return_value = type(
"MockWebAppAuth", (), {"access_mode": "private"}
)()
# Note: get_app_access_mode_by_code method was removed in refactoring
yield {
"passport_service": mock_passport_service,
@@ -866,7 +864,7 @@ class TestWebAppAuthService:
mock_webapp_auth = type("MockWebAppAuth", (), {"access_mode": "sso_verified"})()
mock_external_service_dependencies[
"enterprise_service"
].WebAppAuth.get_app_access_mode_by_code.return_value = mock_webapp_auth
].WebAppAuth.get_app_access_mode_by_id.return_value = mock_webapp_auth
# Act: Execute authentication type determination
result = WebAppAuthService.get_app_auth_type(app_code="mock_app_code")
@@ -877,7 +875,7 @@ class TestWebAppAuthService:
# Verify mock service was called correctly
mock_external_service_dependencies[
"enterprise_service"
].WebAppAuth.get_app_access_mode_by_code.assert_called_once_with("mock_app_code")
].WebAppAuth.get_app_access_mode_by_id.assert_called_once_with("mock_app_id")
def test_get_app_auth_type_no_parameters(self, db_session_with_containers, mock_external_service_dependencies):
"""

View File

@@ -0,0 +1,249 @@
import json
import operator
from typing import TypeVar
from unittest.mock import Mock, patch
import httpx
import pytest
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.custom_tool.tool import ApiTool
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_bundle import ApiToolBundle
from core.tools.entities.tool_entities import (
ToolEntity,
ToolIdentity,
ToolInvokeMessage,
)
_T = TypeVar("_T")
def _get_message_by_type(msgs: list[ToolInvokeMessage], msg_type: type[_T]) -> ToolInvokeMessage | None:
return next((i for i in msgs if isinstance(i.message, msg_type)), None)
class TestApiToolInvoke:
"""Test suite for ApiTool._invoke method to ensure JSON responses are properly serialized."""
def setup_method(self):
"""Setup test fixtures."""
# Create a mock tool entity
self.mock_tool_identity = ToolIdentity(
author="test",
name="test_api_tool",
label=I18nObject(en_US="Test API Tool", zh_Hans="测试API工具"),
provider="test_provider",
)
self.mock_tool_entity = ToolEntity(identity=self.mock_tool_identity)
# Create a mock API bundle
self.mock_api_bundle = ApiToolBundle(
server_url="https://api.example.com/test",
method="GET",
openapi={},
operation_id="test_operation",
parameters=[],
author="test_author",
)
# Create a mock runtime
self.mock_runtime = Mock(spec=ToolRuntime)
self.mock_runtime.credentials = {"auth_type": "none"}
# Create the ApiTool instance
self.api_tool = ApiTool(
entity=self.mock_tool_entity,
api_bundle=self.mock_api_bundle,
runtime=self.mock_runtime,
provider_id="test_provider",
)
@patch("core.tools.custom_tool.tool.ssrf_proxy.get")
def test_invoke_with_json_response_creates_text_message_with_serialized_json(self, mock_get: Mock) -> None:
"""Test that when upstream returns JSON, the output Text message contains JSON-serialized string."""
# Setup mock response with JSON content
json_response_data = {
"key": "value",
"number": 123,
"nested": {"inner": "data"},
}
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.content = json.dumps(json_response_data).encode("utf-8")
mock_response.json.return_value = json_response_data
mock_response.text = json.dumps(json_response_data)
mock_response.headers = {"content-type": "application/json"}
mock_get.return_value = mock_response
# Invoke the tool
result_generator = self.api_tool._invoke(user_id="test_user", tool_parameters={})
# Get the result from the generator
result = list(result_generator)
assert len(result) == 2
# Verify _invoke yields text message
text_message = _get_message_by_type(result, ToolInvokeMessage.TextMessage)
assert text_message is not None, "_invoke should yield a text message"
assert isinstance(text_message, ToolInvokeMessage)
assert text_message.type == ToolInvokeMessage.MessageType.TEXT
assert text_message.message is not None
# Verify the text contains the JSON-serialized string
# Check if message is a TextMessage
assert isinstance(text_message.message, ToolInvokeMessage.TextMessage)
# Verify it's a valid JSON string and equals to the mock response
parsed_back = json.loads(text_message.message.text)
assert parsed_back == json_response_data
# Verify _invoke yields json message
json_message = _get_message_by_type(result, ToolInvokeMessage.JsonMessage)
assert json_message is not None, "_invoke should yield a JSON message"
assert isinstance(json_message, ToolInvokeMessage)
assert json_message.type == ToolInvokeMessage.MessageType.JSON
assert json_message.message is not None
assert isinstance(json_message.message, ToolInvokeMessage.JsonMessage)
assert json_message.message.json_object == json_response_data
@patch("core.tools.custom_tool.tool.ssrf_proxy.get")
@pytest.mark.parametrize(
"test_case",
[
(
"array",
[
{"id": 1, "name": "Item 1", "active": True},
{"id": 2, "name": "Item 2", "active": False},
{"id": 3, "name": "项目 3", "active": True},
],
),
(
"string",
"string",
),
(
"number",
123.456,
),
(
"boolean",
True,
),
(
"null",
None,
),
],
ids=operator.itemgetter(0),
)
def test_invoke_with_non_dict_json_response_creates_text_message_with_serialized_json(
self, mock_get: Mock, test_case
) -> None:
"""Test that when upstream returns a non-dict JSON, the output Text message contains JSON-serialized string."""
# Setup mock response with non-dict JSON content
_, json_value = test_case
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.content = json.dumps(json_value).encode("utf-8")
mock_response.json.return_value = json_value
mock_response.text = json.dumps(json_value)
mock_response.headers = {"content-type": "application/json"}
mock_get.return_value = mock_response
# Invoke the tool
result_generator = self.api_tool._invoke(user_id="test_user", tool_parameters={})
# Get the result from the generator
result = list(result_generator)
assert len(result) == 1
# Verify _invoke yields a text message
text_message = _get_message_by_type(result, ToolInvokeMessage.TextMessage)
assert text_message is not None, "_invoke should yield a text message containing the serialized JSON."
assert isinstance(text_message, ToolInvokeMessage)
assert text_message.type == ToolInvokeMessage.MessageType.TEXT
assert text_message.message is not None
# Verify the text contains the JSON-serialized string
# Check if message is a TextMessage
assert isinstance(text_message.message, ToolInvokeMessage.TextMessage)
# Verify it's a valid JSON string
parsed_back = json.loads(text_message.message.text)
assert parsed_back == json_value
# Verify _invoke yields json message
json_message = _get_message_by_type(result, ToolInvokeMessage.JsonMessage)
assert json_message is None, "_invoke should not yield a JSON message for JSON array response"
@patch("core.tools.custom_tool.tool.ssrf_proxy.get")
def test_invoke_with_text_response_creates_text_message_with_original_text(self, mock_get: Mock) -> None:
"""Test that when upstream returns plain text, the output Text message contains the original text."""
# Setup mock response with plain text content
text_response_data = "This is a plain text response"
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.content = text_response_data.encode("utf-8")
mock_response.json.side_effect = json.JSONDecodeError("Expecting value", "doc", 0)
mock_response.text = text_response_data
mock_response.headers = {"content-type": "text/plain"}
mock_get.return_value = mock_response
# Invoke the tool
result_generator = self.api_tool._invoke(user_id="test_user", tool_parameters={})
# Get the result from the generator
result = list(result_generator)
assert len(result) == 1
# Verify it's a text message with the original text
message = result[0]
assert isinstance(message, ToolInvokeMessage)
assert message.type == ToolInvokeMessage.MessageType.TEXT
assert message.message is not None
# Check if message is a TextMessage
assert isinstance(message.message, ToolInvokeMessage.TextMessage)
assert message.message.text == text_response_data
@patch("core.tools.custom_tool.tool.ssrf_proxy.get")
def test_invoke_with_empty_response(self, mock_get: Mock) -> None:
"""Test that empty responses are handled correctly."""
# Setup mock response with empty content
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.content = b""
mock_response.headers = {"content-type": "application/json"}
mock_get.return_value = mock_response
# Invoke the tool
result_generator = self.api_tool._invoke(user_id="test_user", tool_parameters={})
# Get the result from the generator
result = list(result_generator)
assert len(result) == 1
# Verify it's a text message with the empty response message
message = result[0]
assert isinstance(message, ToolInvokeMessage)
assert message.type == ToolInvokeMessage.MessageType.TEXT
assert message.message is not None
# Check if message is a TextMessage
assert isinstance(message.message, ToolInvokeMessage.TextMessage)
assert "Empty response from the tool" in message.message.text
@patch("core.tools.custom_tool.tool.ssrf_proxy.get")
def test_invoke_with_error_response(self, mock_get: Mock) -> None:
"""Test that error responses are handled correctly."""
# Setup mock response with error status code
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 404
mock_response.text = "Not Found"
mock_get.return_value = mock_response
result_generator = self.api_tool._invoke(user_id="test_user", tool_parameters={})
# Invoke the tool and expect an error
with pytest.raises(Exception) as exc_info:
list(result_generator) # Consume the generator to trigger the error
# Verify the error message
assert "Request failed with status code 404" in str(exc_info.value)

View File

@@ -100,7 +100,10 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
})
}
}
catch (e: any) {
if (e.code === 'authentication_failed')
Toast.notify({ type: 'error', message: e.message })
}
finally {
setIsLoading(false)
}

View File

@@ -17,12 +17,9 @@ import type {
import { noop } from 'lodash-es'
export type EmbeddedChatbotContextValue = {
userCanAccess?: boolean
appInfoError?: any
appInfoLoading?: boolean
appMeta?: AppMeta
appData?: AppData
appParams?: ChatConfig
appMeta: AppMeta | null
appData: AppData | null
appParams: ChatConfig | null
appChatListDataLoading?: boolean
currentConversationId: string
currentConversationItem?: ConversationItem
@@ -59,7 +56,10 @@ export type EmbeddedChatbotContextValue = {
}
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
userCanAccess: false,
appData: null,
appMeta: null,
appParams: null,
appChatListDataLoading: false,
currentConversationId: '',
appPrevChatList: [],
pinnedConversationList: [],

View File

@@ -18,9 +18,6 @@ import { CONVERSATION_ID_INFO } from '../constants'
import { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams, getProcessedUserVariablesFromUrlParams } from '../utils'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
import {
fetchAppInfo,
fetchAppMeta,
fetchAppParams,
fetchChatList,
fetchConversations,
generationConversationName,
@@ -36,8 +33,7 @@ import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
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[] = []
@@ -67,18 +63,10 @@ function getFormattedChatList(messages: any[]) {
export const useEmbeddedChatbot = () => {
const isInstalledApp = false
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo)
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
appId: appInfo?.app_id,
isInstalledApp,
enabled: systemFeatures.webapp_auth.enabled,
})
const appData = useMemo(() => {
return appInfo
}, [appInfo])
const appId = useMemo(() => appData?.app_id, [appData])
const appInfo = useWebAppStore(s => s.appInfo)
const appMeta = useWebAppStore(s => s.appMeta)
const appParams = useWebAppStore(s => s.appParams)
const appId = useMemo(() => appInfo?.app_id, [appInfo])
const [userId, setUserId] = useState<string>()
const [conversationId, setConversationId] = useState<string>()
@@ -145,8 +133,6 @@ export const useEmbeddedChatbot = () => {
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 } = 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))
@@ -395,16 +381,13 @@ export const useEmbeddedChatbot = () => {
}, [isInstalledApp, appId, t, notify])
return {
appInfoError,
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission),
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
isInstalledApp,
allowResetChat,
appId,
currentConversationId,
currentConversationItem,
handleConversationIdInfoChange,
appData,
appData: appInfo,
appParams: appParams || {} as ChatConfig,
appMeta,
appPinnedConversationData,

View File

@@ -101,7 +101,6 @@ const EmbeddedChatbotWrapper = () => {
const {
appData,
userCanAccess,
appParams,
appMeta,
appChatListDataLoading,
@@ -135,7 +134,6 @@ const EmbeddedChatbotWrapper = () => {
} = useEmbeddedChatbot()
return <EmbeddedChatbotContext.Provider value={{
userCanAccess,
appData,
appParams,
appMeta,

View File

@@ -135,8 +135,8 @@ const NormalForm = () => {
{!systemFeatures.branding.enabled && <p className='body-md-regular mt-2 text-text-tertiary'>{t('login.joinTipStart')}{workspaceName}{t('login.joinTipEnd')}</p>}
</div>
: <div className="mx-auto w-full">
<h2 className="title-4xl-semi-bold text-text-primary">{t('login.pageTitle')}</h2>
{!systemFeatures.branding.enabled && <p className='body-md-regular mt-2 text-text-tertiary'>{t('login.welcome')}</p>}
<h2 className="title-4xl-semi-bold text-text-primary">{systemFeatures.branding.enabled ? t('login.pageTitleForE') : t('login.pageTitle')}</h2>
<p className='body-md-regular mt-2 text-text-tertiary'>{t('login.welcome')}</p>
</div>}
<div className="relative">
<div className="mt-6 flex flex-col gap-3">

View File

@@ -1,5 +1,6 @@
const translation = {
pageTitle: 'Log in to Dify',
pageTitleForE: 'Hey, let\'s get started!',
welcome: '👋 Welcome! Please log in to get started.',
email: 'Email address',
emailPlaceholder: 'Your email',

View File

@@ -1,5 +1,6 @@
const translation = {
pageTitle: 'Dify にログイン',
pageTitleForE: 'はじめましょう!',
welcome: '👋 ようこそ!まずはログインしてご利用ください。',
email: 'メールアドレス',
emailPlaceholder: 'メールアドレスを入力してください',

View File

@@ -1,5 +1,6 @@
const translation = {
pageTitle: '登录 Dify',
pageTitleForE: '嗨,近来可好',
welcome: '👋 欢迎!请登录以开始使用。',
email: '邮箱',
emailPlaceholder: '输入邮箱地址',

View File

@@ -1,5 +1,6 @@
const translation = {
pageTitle: '嗨,近來可好',
pageTitleForE: '嗨,近來可好',
welcome: '👋 歡迎來到 Dify, 登入以繼續',
email: '郵箱',
emailPlaceholder: '輸入郵箱地址',