Compare commits

..

28 Commits

Author SHA1 Message Date
adb1f8b7ca fix i18n of transfer warning 2025-07-15 14:06:55 +08:00
ac7d258914 Merge branch 'deploy/enterprise' of github.com:langgenius/dify into temp-feat-owner-transfer-enterprise-frontend 2025-07-15 14:02:44 +08:00
28fca88b35 Temp feat owner transfer enterprise frontend (#22400)
Co-authored-by: JzoNg <jzongcode@gmail.com>
2025-07-15 13:45:03 +08:00
21756f1110 Temp feat change user email enterprise frontend (#22401)
Co-authored-by: JzoNg <jzongcode@gmail.com>
2025-07-15 13:43:34 +08:00
221187c1bc fix conflict 2025-07-15 13:36:52 +08:00
4176bca008 fix i18n of transfer warning 2025-07-15 13:34:26 +08:00
9c4f24c6d7 Temp feat owner transfer enterprise frontend (#22393)
Co-authored-by: JzoNg <jzongcode@gmail.com>
2025-07-15 11:45:04 +08:00
3ab3bee9c6 fix judgement of workspace transfer 2025-07-15 11:40:20 +08:00
a93c76199e correct api of email sending 2025-07-15 11:40:14 +08:00
77c91cdd63 add judgement of system features 2025-07-15 11:40:09 +08:00
59a3686379 ownership transfer 2025-07-15 11:40:01 +08:00
077f44ff58 add apis 2025-07-15 11:39:56 +08:00
58f67acf07 ownership transfer styling 2025-07-15 11:39:50 +08:00
856d07ca2f Temp feat change user email enterprise (#22390) 2025-07-15 11:27:02 +08:00
2a0a315187 Merge branch 'perf/web-app-authrozation' into deploy/enterprise 2025-07-14 11:29:57 +08:00
3e18dd41e7 fix: show App is UnAvailable when no app info found in explore page 2025-07-14 11:29:37 +08:00
128f8de382 Merge branch 'perf/web-app-authrozation' into deploy/enterprise 2025-07-10 17:50:26 +08:00
8ead8961ce fix: check user can access web app in background task 2025-07-10 17:43:14 +08:00
8a888e082d Merge branch 'perf/web-app-authrozation' into deploy/enterprise 2025-07-10 17:27:34 +08:00
513af98729 perf: use component to handle webapp info fetch, authorization 2025-07-10 17:27:12 +08:00
35d2a935a3 Merge branch 'fix/allow-update-plugin-settings' into deploy/enterprise 2025-07-09 14:48:45 +08:00
207d0f78f5 fix: allow update plugin install settings 2025-07-09 14:48:32 +08:00
56e8b9711d Merge branch 'perf/web-app-authrozation' into deploy/enterprise 2025-07-08 19:56:00 +08:00
e4b6c33bdd wip: Adjust the authentication process to improve user experience 2025-07-08 19:55:46 +08:00
c0e73c1659 Merge branch 'fix/install-preference-error' into deploy/enterprise 2025-07-08 14:28:04 +08:00
434c809866 don't add search params when opening detail links from marketplace. 2025-07-08 14:24:54 +08:00
6ab09397ef Merge branch 'perf/web-app-authrozation' into deploy/enterprise 2025-07-02 14:14:22 +08:00
3bead19f19 wip: enhance user experience by refining authorization verification process 2025-07-02 14:14:05 +08:00
120 changed files with 3754 additions and 805 deletions

View File

@ -477,6 +477,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

View File

@ -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",
@ -580,6 +589,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):
"""

View File

@ -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

View File

@ -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,137 @@ 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()
# Must use token in reset phase
if reset_data.get("phase", "") != "change_email":
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 +528,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')

View File

@ -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")

View File

@ -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

View File

@ -122,6 +122,7 @@ class TencentVector(BaseVector):
metric_type,
params,
)
index_text = vdb_index.FilterIndex(self.field_text, enum.FieldType.String, enum.IndexType.FILTER)
index_metadate = vdb_index.FilterIndex(self.field_metadata, enum.FieldType.Json, enum.IndexType.FILTER)
index_sparse_vector = vdb_index.SparseIndex(
name="sparse_vector",
@ -129,7 +130,7 @@ class TencentVector(BaseVector):
index_type=enum.IndexType.SPARSE_INVERTED,
metric_type=enum.MetricType.IP,
)
indexes = [index_id, index_vector, index_metadate]
indexes = [index_id, index_vector, index_text, index_metadate]
if self._enable_hybrid_search:
indexes.append(index_sparse_vector)
try:
@ -148,7 +149,7 @@ class TencentVector(BaseVector):
index_metadate = vdb_index.FilterIndex(
self.field_metadata, enum.FieldType.String, enum.IndexType.FILTER
)
indexes = [index_id, index_vector, index_metadate]
indexes = [index_id, index_vector, index_text, index_metadate]
if self._enable_hybrid_search:
indexes.append(index_sparse_vector)
self._client.create_collection(

View File

@ -17,7 +17,6 @@ from core.workflow.entities.workflow_execution import (
)
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter
from libs.helper import extract_tenant_id
from models import (
Account,
CreatorUserRole,
@ -68,7 +67,7 @@ class SQLAlchemyWorkflowExecutionRepository(WorkflowExecutionRepository):
)
# Extract tenant_id from user
tenant_id = extract_tenant_id(user)
tenant_id: str | None = user.tenant_id if isinstance(user, EndUser) else user.current_tenant_id
if not tenant_id:
raise ValueError("User must have a tenant_id or current_tenant_id")
self._tenant_id = tenant_id

View File

@ -20,7 +20,6 @@ from core.workflow.entities.workflow_node_execution import (
from core.workflow.nodes.enums import NodeType
from core.workflow.repositories.workflow_node_execution_repository import OrderConfig, WorkflowNodeExecutionRepository
from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter
from libs.helper import extract_tenant_id
from models import (
Account,
CreatorUserRole,
@ -71,7 +70,7 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
)
# Extract tenant_id from user
tenant_id = extract_tenant_id(user)
tenant_id: str | None = user.tenant_id if isinstance(user, EndUser) else user.current_tenant_id
if not tenant_id:
raise ValueError("User must have a tenant_id or current_tenant_id")
self._tenant_id = tenant_id

View File

@ -12,7 +12,6 @@ from flask_login import user_loaded_from_request, user_logged_in # type: ignore
from configs import dify_config
from dify_app import DifyApp
from libs.helper import extract_tenant_id
from models import Account, EndUser
@ -25,8 +24,11 @@ def on_user_loaded(_sender, user: Union["Account", "EndUser"]):
if user:
try:
current_span = get_current_span()
tenant_id = extract_tenant_id(user)
if not tenant_id:
if isinstance(user, Account) and user.current_tenant_id:
tenant_id = user.current_tenant_id
elif isinstance(user, EndUser):
tenant_id = user.tenant_id
else:
return
if current_span:
current_span.set_attribute("service.tenant.id", tenant_id)

View File

@ -17,7 +17,6 @@ class EnvironmentVariableField(fields.Raw):
"name": value.name,
"value": encrypter.obfuscated_token(value.value),
"value_type": value.value_type.value,
"description": value.description,
}
if isinstance(value, Variable):
return {
@ -25,7 +24,6 @@ class EnvironmentVariableField(fields.Raw):
"name": value.name,
"value": value.value,
"value_type": value.value_type.value,
"description": value.description,
}
if isinstance(value, dict):
value_type = value.get("value_type")

View File

@ -25,31 +25,6 @@ from extensions.ext_redis import redis_client
if TYPE_CHECKING:
from models.account import Account
from models.model import EndUser
def extract_tenant_id(user: Union["Account", "EndUser"]) -> str | None:
"""
Extract tenant_id from Account or EndUser object.
Args:
user: Account or EndUser object
Returns:
tenant_id string if available, None otherwise
Raises:
ValueError: If user is neither Account nor EndUser
"""
from models.account import Account
from models.model import EndUser
if isinstance(user, Account):
return user.current_tenant_id
elif isinstance(user, EndUser):
return user.tenant_id
else:
raise ValueError(f"Invalid user type: {type(user)}. Expected Account or EndUser.")
def run(script):

View File

@ -15,7 +15,6 @@ from core.variables import utils as variable_utils
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from core.workflow.nodes.enums import NodeType
from factories.variable_factory import TypeMismatchError, build_segment_with_type
from libs.helper import extract_tenant_id
from ._workflow_exc import NodeNotFoundError, WorkflowDataError
@ -353,7 +352,12 @@ class Workflow(Base):
self._environment_variables = "{}"
# Get tenant_id from current_user (Account or EndUser)
tenant_id = extract_tenant_id(current_user)
if isinstance(current_user, Account):
# Account user
tenant_id = current_user.current_tenant_id
else:
# EndUser
tenant_id = current_user.tenant_id
if not tenant_id:
return []
@ -380,7 +384,12 @@ class Workflow(Base):
return
# Get tenant_id from current_user (Account or EndUser)
tenant_id = extract_tenant_id(current_user)
if isinstance(current_user, Account):
# Account user
tenant_id = current_user.current_tenant_id
else:
# EndUser
tenant_id = current_user.tenant_id
if not tenant_id:
self._environment_variables = "{}"

View File

@ -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"
@ -546,6 +702,56 @@ class AccountService:
key = f"forgot_password_error_rate_limit:{email}"
redis_client.delete(key)
@staticmethod
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
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
def reset_change_email_error_rate_limit(email: str):
key = f"change_email_error_rate_limit:{email}"
redis_client.delete(key)
@staticmethod
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
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
def reset_owner_transfer_error_rate_limit(email: str):
key = f"owner_transfer_error_rate_limit:{email}"
redis_client.delete(key)
@staticmethod
def is_email_send_ip_limit(ip_address: str):
minute_key = f"email_send_ip_limit_minute:{ip_address}"
@ -586,6 +792,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
@ -858,6 +1068,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

View File

@ -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"]

View File

@ -18,7 +18,6 @@ from core.file import helpers as file_helpers
from core.rag.extractor.extract_processor import ExtractProcessor
from extensions.ext_database import db
from extensions.ext_storage import storage
from libs.helper import extract_tenant_id
from models.account import Account
from models.enums import CreatorUserRole
from models.model import EndUser, UploadFile
@ -62,7 +61,11 @@ class FileService:
# generate file key
file_uuid = str(uuid.uuid4())
current_tenant_id = extract_tenant_id(user)
if isinstance(user, Account):
current_tenant_id = user.current_tenant_id
else:
# end_user
current_tenant_id = user.tenant_id
file_key = "upload_files/" + (current_tenant_id or "") + "/" + file_uuid + "." + extension

View File

@ -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))

View File

@ -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))

View File

@ -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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description">Youre updating the email address linked to your Dify account.
To confirm this action, please use the verification code below.
This code will only be valid for the next 5 minutes:</p>
<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>

View File

@ -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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description">您正在更新与您的 Dify 账户关联的邮箱地址。
为了确认此操作,请使用以下验证码。
此验证码仅在接下来的5分钟内有效</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
</div>
</body>
</html>

View File

@ -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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description">We received a request to change the email address associated with your Dify account.
To confirm this action, please use the verification code below.
This code will only be valid for the next 5 minutes:</p>
<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>

View File

@ -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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description"> 我们收到了一个变更您 Dify 账户关联邮箱地址的请求。
我们收到了一个变更您 Dify 账户关联邮箱地址的请求。
此验证码仅在接下来的5分钟内有效</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
</div>
</body>
</html>

View File

@ -0,0 +1,86 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description">You have been assigned as the new owner of the workspace "{{WorkspaceName}}".
As the new owner, you now have full administrative privileges for this workspace.
If you have any questions, please contact support@dify.ai.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,86 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description">您已被分配为工作空间“{{WorkspaceName}}”的新所有者。
作为新所有者,您现在对该工作空间拥有完全的管理权限。
如果您有任何问题请联系support@dify.ai。</p>
</div>
</body>
</html>

View File

@ -0,0 +1,88 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description">You have successfully transferred ownership of the workspace "{{WorkspaceName}}" to
{{NewOwnerEmail}}.
You no longer have owner privileges for this workspace. Your access level has been changed to Admin.
If you did not initiate this transfer or have concerns about this change, please contact support@dify.ai
immediately.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,86 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description">您已成功将工作空间“{{WorkspaceName}}”的所有权转移给{{NewOwnerEmail}}。
您不再拥有此工作空间的拥有者权限。您的访问级别已更改为管理员。
如果您没有发起此转移或对此变更有任何疑问请立即联系support@dify.ai。</p>
</div>
</body>
</html>

View File

@ -0,0 +1,91 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description">We received a request to transfer ownership of your workspace “{{WorkspaceName}}”.
To confirm this action, please use the verification code below.
This code will only be valid for the next 5 minutes:</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">Please note: The ownership transfer will take effect immediately once confirmed and cannot be
undone.
Youll become a admin member, and the new owner will have full control of the workspace.If you didnt make this
request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,90 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</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>
<p class="description">我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移的请求。
为了确认此操作,请使用以下验证码。
此验证码仅在5分钟内有效</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">请注意:所有权转移一旦确认将立即生效且无法撤销。您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。
如果您没有发起此请求,请忽略此邮件或立即联系客服。
</p>
</div>
</body>
</html>

View File

@ -0,0 +1,85 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">Confirm Your New Email Address</p>
<p class="description">Youre updating the email address linked to your Dify account.
To confirm this action, please use the verification code below.
This code will only be valid for the next 5 minutes:</p>
<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>

View File

@ -0,0 +1,85 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">确认您的邮箱地址变更</p>
<p class="description">您正在更新与您的 Dify 账户关联的邮箱地址。
为了确认此操作,请使用以下验证码。
此验证码仅在接下来的5分钟内有效</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
</div>
</body>
</html>

View File

@ -0,0 +1,85 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">Verify Your Request to Change Email</p>
<p class="description">We received a request to change the email address associated with your Dify account.
To confirm this action, please use the verification code below.
This code will only be valid for the next 5 minutes:</p>
<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>

View File

@ -0,0 +1,85 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">验证您的邮箱变更请求</p>
<p class="description"> 我们收到了一个变更您 Dify 账户关联邮箱地址的请求。
为了确认此操作,请使用以下验证码。
此验证码仅在接下来的5分钟内有效</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
</div>
</body>
</html>

View File

@ -0,0 +1,81 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">You are now the owner of {{WorkspaceName}}</p>
<p class="description">You have been assigned as the new owner of the workspace "{{WorkspaceName}}".
As the new owner, you now have full administrative privileges for this workspace.
If you have any questions, please contact support@dify.ai.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,81 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">您现在是 {{WorkspaceName}} 的所有者</p>
<p class="description">您已被分配为工作空间“{{WorkspaceName}}”的新所有者。
作为新所有者,您现在对该工作空间拥有完全的管理权限。
如果您有任何问题请联系support@dify.ai。</p>
</div>
</body>
</html>

View File

@ -0,0 +1,83 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">Workspace ownership has been transferred</p>
<p class="description">You have successfully transferred ownership of the workspace "{{WorkspaceName}}" to
{{NewOwnerEmail}}.
You no longer have owner privileges for this workspace. Your access level has been changed to Admin.
If you did not initiate this transfer or have concerns about this change, please contact support@dify.ai
immediately.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,81 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">工作区所有权已转移</p>
<p class="description">您已成功将工作空间“{{WorkspaceName}}”的所有权转移给{{NewOwnerEmail}}。
您不再拥有此工作空间的拥有者权限。您的访问级别已更改为管理员。
如果您没有发起此转移或对此变更有任何疑问请立即联系support@dify.ai。</p>
</div>
</body>
</html>

View File

@ -0,0 +1,88 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">Verify Your Request to Transfer Workspace Ownership</p>
<p class="description">We received a request to transfer ownership of your workspace “{{WorkspaceName}}”.
To confirm this action, please use the verification code below.
This code will only be valid for the next 5 minutes:</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">Please note: The ownership transfer will take effect immediately once confirmed and cannot be
undone.
Youll become a admin member, and the new owner will have full control of the workspace.If you didnt make
this
request, please ignore this email or contact support immediately.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,87 @@
<!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: 600px;
height: 360px;
margin: 40px auto;
padding: 36px 48px;
background-color: #fcfcfd;
border-radius: 16px;
border: 1px solid #ffffff;
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
}
.header {
margin-bottom: 24px;
}
.header img {
max-width: 100px;
height: auto;
}
.title {
font-weight: 600;
font-size: 24px;
line-height: 28.8px;
}
.description {
font-size: 13px;
line-height: 16px;
color: #676f83;
margin-top: 12px;
}
.code-content {
padding: 16px 32px;
text-align: center;
border-radius: 16px;
background-color: #f2f4f7;
margin: 16px auto;
}
.code {
line-height: 36px;
font-weight: 700;
font-size: 30px;
}
.tips {
line-height: 16px;
color: #676f83;
font-size: 13px;
}
</style>
</head>
<body>
<div class="container">
<p class="title">验证您的工作空间所有权转移请求</p>
<p class="description">我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移的请求。
为了确认此操作,请使用以下验证码。
此验证码仅在5分钟内有效</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">请注意:所有权转移一旦确认将立即生效且无法撤销。
您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。如果您没有发起此请求,请忽略此邮件或立即联系客服。
</p>
</div>
</body>
</html>

View File

@ -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

View File

@ -1,65 +0,0 @@
import pytest
from libs.helper import extract_tenant_id
from models.account import Account
from models.model import EndUser
class TestExtractTenantId:
"""Test cases for the extract_tenant_id utility function."""
def test_extract_tenant_id_from_account_with_tenant(self):
"""Test extracting tenant_id from Account with current_tenant_id."""
# Create a mock Account object
account = Account()
# Mock the current_tenant_id property
account._current_tenant = type("MockTenant", (), {"id": "account-tenant-123"})()
tenant_id = extract_tenant_id(account)
assert tenant_id == "account-tenant-123"
def test_extract_tenant_id_from_account_without_tenant(self):
"""Test extracting tenant_id from Account without current_tenant_id."""
# Create a mock Account object
account = Account()
account._current_tenant = None
tenant_id = extract_tenant_id(account)
assert tenant_id is None
def test_extract_tenant_id_from_enduser_with_tenant(self):
"""Test extracting tenant_id from EndUser with tenant_id."""
# Create a mock EndUser object
end_user = EndUser()
end_user.tenant_id = "enduser-tenant-456"
tenant_id = extract_tenant_id(end_user)
assert tenant_id == "enduser-tenant-456"
def test_extract_tenant_id_from_enduser_without_tenant(self):
"""Test extracting tenant_id from EndUser without tenant_id."""
# Create a mock EndUser object
end_user = EndUser()
end_user.tenant_id = None
tenant_id = extract_tenant_id(end_user)
assert tenant_id is None
def test_extract_tenant_id_with_invalid_user_type(self):
"""Test extracting tenant_id with invalid user type raises ValueError."""
invalid_user = "not_a_user_object"
with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"):
extract_tenant_id(invalid_user)
def test_extract_tenant_id_with_none_user(self):
"""Test extracting tenant_id with None user raises ValueError."""
with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"):
extract_tenant_id(None)
def test_extract_tenant_id_with_dict_user(self):
"""Test extracting tenant_id with dict user raises ValueError."""
dict_user = {"id": "123", "tenant_id": "456"}
with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"):
extract_tenant_id(dict_user)

View File

@ -9,7 +9,6 @@ from core.file.models import File
from core.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable
from core.variables.segments import IntegerSegment, Segment
from factories.variable_factory import build_segment
from models.model import EndUser
from models.workflow import Workflow, WorkflowDraftVariable, WorkflowNodeExecutionModel, is_system_variable_editable
@ -44,7 +43,7 @@ def test_environment_variables():
)
# Mock current_user as an EndUser
mock_user = mock.Mock(spec=EndUser)
mock_user = mock.Mock()
mock_user.tenant_id = "tenant_id"
with (
@ -91,7 +90,7 @@ def test_update_environment_variables():
)
# Mock current_user as an EndUser
mock_user = mock.Mock(spec=EndUser)
mock_user = mock.Mock()
mock_user.tenant_id = "tenant_id"
with (
@ -137,7 +136,7 @@ def test_to_dict():
# Create some EnvironmentVariable instances
# Mock current_user as an EndUser
mock_user = mock.Mock(spec=EndUser)
mock_user = mock.Mock()
mock_user.tenant_id = "tenant_id"
with (

View File

@ -763,6 +763,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

View File

@ -332,6 +332,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}

View File

@ -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

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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

View File

@ -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>
)
}

View File

@ -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')}>

View File

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

View File

@ -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>

View File

@ -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>
)
}

View File

@ -39,7 +39,6 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
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)
@ -73,7 +72,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}
}
const verifyEmailAddress = async (email: string, code: string, token: string, callback?: (data?: any) => void) => {
const verifyEmailAddress = async (email: string, code: string, token: string, callback?: () => void) => {
try {
const res = await verifyEmail({
email,
@ -82,7 +81,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
})
if (res.is_valid) {
setStepToken(res.token)
callback?.(res.token)
callback?.()
}
else {
notify({
@ -118,24 +117,28 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}
const checkNewEmailExisted = async (email: string) => {
setIsCheckingEmail(true)
try {
await checkEmailExisted({
email,
})
setNewEmailExited(false)
}
catch {
setNewEmailExited(true)
}
finally {
setIsCheckingEmail(false)
catch (error) {
setNewEmailExited(false)
if ((error as any)?.code === 'email_already_in_use') {
setNewEmailExited(true)
}
else {
notify({
type: 'error',
message: `Error checking email existence: ${error ? (error as any).message : ''}`,
})
}
}
}
const handleNewEmailValueChange = (mailAddress: string) => {
setMail(mailAddress)
setNewEmailExited(false)
if (isValidEmail(mailAddress))
checkNewEmailExisted(mailAddress)
}
@ -169,11 +172,11 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
router.push('/signin')
}
const updateEmail = async (lastToken: string) => {
const updateEmail = async () => {
try {
await resetEmail({
new_email: mail,
token: lastToken,
token: stepToken,
})
handleLogout()
}
@ -186,7 +189,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}
const submitNewEmail = async () => {
await verifyEmailAddress(mail, code, stepToken, updateEmail)
await verifyEmailAddress(mail, code, stepToken, () => updateEmail())
}
return (
@ -299,7 +302,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={!mail || newEmailExited || isCheckingEmail || !isValidEmail(mail)}
disabled={!mail}
className='!w-full'
variant='primary'
onClick={sendCodeToNewEmail}

View File

@ -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: [],

View File

@ -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,

View File

@ -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,

View File

@ -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 />
}

View File

@ -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 = {

View File

@ -101,7 +101,7 @@ const Tooltip: FC<TooltipProps> = ({
>
{popupContent && (<div
className={cn(
!noDecoration && 'system-xs-regular relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg',
!noDecoration && 'system-xs-regular relative break-words rounded-md bg-components-panel-bg px-3 py-2 text-text-tertiary shadow-lg',
popupClassName,
)}
onMouseEnter={() => triggerMethod === 'hover' && setHoverPopup()}

View File

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

View File

@ -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,
}
}
>

View File

@ -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 })
if (!installedApp) {
return (
<div className='flex h-full items-center'>
<Loading type='area' />
</div>
)
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 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>

View File

@ -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 (

View File

@ -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 (
<>
@ -133,11 +136,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>
))
@ -173,6 +183,12 @@ const MembersPage = () => {
/>
)
}
{showTransferOwnershipModal && (
<TransferOwnershipModal
show={showTransferOwnershipModal}
onClose={() => setShowTransferOwnershipModal(false)}
/>
)}
</>
)
}

View File

@ -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

View File

@ -0,0 +1,255 @@
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({
language: userProfile.interface_language,
})
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

View File

@ -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

View File

@ -136,7 +136,7 @@ const PluginPage = ({
const options = usePluginPageContext(v => v.options)
const activeTab = usePluginPageContext(v => v.activeTab)
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
const { enable_marketplace, branding } = useGlobalPublicStore(s => s.systemFeatures)
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab])
const isExploringMarketplace = useMemo(() => {
@ -225,7 +225,7 @@ const PluginPage = ({
)
}
{
canSetPermissions && !branding.enabled && (
canSetPermissions && (
<Tooltip
popupContent={t('plugin.privilege.title')}
>

View File

@ -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>

View File

@ -18,8 +18,8 @@ import {
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import type { SiteInfo } from '@/models/share'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode } from '@/models/access-control'
import { useWebAppStore } from '@/context/web-app-context'
type Props = {
data?: SiteInfo
@ -32,7 +32,7 @@ const MenuDropdown: FC<Props> = ({
placement,
hideLogout,
}) => {
const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode)
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
const router = useRouter()
const pathname = usePathname()
const { t } = useTranslation()

View File

@ -10,7 +10,7 @@ export const getInitialTokenV2 = (): Record<string, any> => ({
version: 2,
})
export const checkOrSetAccessToken = async (appCode?: string) => {
export const checkOrSetAccessToken = async (appCode?: string | null) => {
const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0]
const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id
const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2())

View File

@ -462,7 +462,6 @@ const formatItem = (
return {
variable: `env.${env.name}`,
type: env.value_type,
description: env.description,
}
}) as Var[]
break
@ -473,7 +472,7 @@ const formatItem = (
return {
variable: `conversation.${chatVar.name}`,
type: chatVar.value_type,
description: chatVar.description,
des: chatVar.description,
}
}) as Var[]
break

View File

@ -22,7 +22,6 @@ const nodeDefault: NodeDefault<HttpNodeType> = {
type: BodyType.none,
data: [],
},
ssl_verify: true,
timeout: {
max_connect_timeout: 0,
max_read_timeout: 0,

View File

@ -10,7 +10,6 @@ import type { HttpNodeType } from './types'
import Timeout from './components/timeout'
import CurlPanel from './components/curl-panel'
import cn from '@/utils/classnames'
import Switch from '@/app/components/base/switch'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
@ -48,7 +47,6 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
showCurlPanel,
hideCurlPanel,
handleCurlImport,
handleSSLVerifyChange,
} = useConfig(id, data)
// To prevent prompt editor in body not update data.
if (!isDataReady)
@ -126,18 +124,6 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
onChange={setBody}
/>
</Field>
<Field
title={t(`${i18nPrefix}.verifySSL.title`)}
tooltip={t(`${i18nPrefix}.verifySSL.warningTooltip`)}
operations={
<Switch
defaultValue={!!inputs.ssl_verify}
onChange={handleSSLVerifyChange}
size='md'
disabled={readOnly}
/>
}>
</Field>
</div>
<Split />
<Timeout

View File

@ -81,5 +81,4 @@ export type HttpNodeType = CommonNodeType & {
body: Body
authorization: Authorization
timeout: Timeout
ssl_verify?: boolean
}

View File

@ -141,13 +141,6 @@ const useConfig = (id: string, payload: HttpNodeType) => {
setInputs(newInputs)
}, [inputs, setInputs])
const handleSSLVerifyChange = useCallback((checked: boolean) => {
const newInputs = produce(inputs, (draft: HttpNodeType) => {
draft.ssl_verify = checked
})
setInputs(newInputs)
}, [inputs, setInputs])
return {
readOnly,
isDataReady,
@ -171,8 +164,6 @@ const useConfig = (id: string, payload: HttpNodeType) => {
toggleIsParamKeyValueEdit,
// body
setBody,
// ssl verify
handleSSLVerifyChange,
// authorization
isShowAuthorization,
showAuthorization,

View File

@ -80,7 +80,7 @@ const ChatVariableModal = ({
const [objectValue, setObjectValue] = React.useState<ObjectValueItem[]>([DEFAULT_OBJECT_VALUE])
const [editorContent, setEditorContent] = React.useState<string>()
const [editInJSON, setEditInJSON] = React.useState(false)
const [description, setDescription] = React.useState<string>('')
const [des, setDes] = React.useState<string>('')
const editorMinHeight = useMemo(() => {
if (type === ChatVarType.ArrayObject)
@ -237,7 +237,7 @@ const ChatVariableModal = ({
name,
value_type: type,
value: formatValue(value),
description,
description: des,
})
onClose()
}
@ -247,7 +247,7 @@ const ChatVariableModal = ({
setName(chatVar.name)
setType(chatVar.value_type)
setValue(chatVar.value)
setDescription(chatVar.description)
setDes(chatVar.description)
setObjectValue(getObjectValue())
if (chatVar.value_type === ChatVarType.ArrayObject) {
setEditorContent(JSON.stringify(chatVar.value))
@ -385,9 +385,9 @@ const ChatVariableModal = ({
<div className='flex'>
<textarea
className='system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs'
value={description}
value={des}
placeholder={t('workflow.chatVariable.modal.descriptionPlaceholder') || ''}
onChange={e => setDescription(e.target.value)}
onChange={e => setDes(e.target.value)}
/>
</div>
</div>

View File

@ -22,42 +22,30 @@ const EnvItem = ({
return (
<div className={cn(
'radius-md group mb-1 border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
'radius-md mb-1 border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 py-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
destructive && 'border-state-destructive-border hover:bg-state-destructive-hover',
)}>
<div className='px-2.5 py-2'>
<div className='flex items-center justify-between'>
<div className='flex grow items-center gap-1'>
<Env className='h-4 w-4 text-util-colors-violet-violet-600' />
<div className='system-sm-medium text-text-primary'>{env.name}</div>
<div className='system-xs-medium text-text-tertiary'>{capitalize(env.value_type)}</div>
{env.value_type === 'secret' && <RiLock2Line className='h-3 w-3 text-text-tertiary' />}
<div className='flex items-center justify-between'>
<div className='flex grow items-center gap-1'>
<Env className='h-4 w-4 text-util-colors-violet-violet-600' />
<div className='system-sm-medium text-text-primary'>{env.name}</div>
<div className='system-xs-medium text-text-tertiary'>{capitalize(env.value_type)}</div>
{env.value_type === 'secret' && <RiLock2Line className='h-3 w-3 text-text-tertiary' />}
</div>
<div className='flex shrink-0 items-center gap-1 text-text-tertiary'>
<div className='radius-md cursor-pointer p-1 hover:bg-state-base-hover hover:text-text-secondary'>
<RiEditLine className='h-4 w-4' onClick={() => onEdit(env)}/>
</div>
<div className='flex shrink-0 items-center gap-1 text-text-tertiary'>
<div className='radius-md cursor-pointer p-1 hover:bg-state-base-hover hover:text-text-secondary'>
<RiEditLine className='h-4 w-4' onClick={() => onEdit(env)}/>
</div>
<div
className='radius-md cursor-pointer p-1 hover:bg-state-destructive-hover hover:text-text-destructive'
onMouseOver={() => setDestructive(true)}
onMouseOut={() => setDestructive(false)}
>
<RiDeleteBinLine className='h-4 w-4' onClick={() => onDelete(env)} />
</div>
<div
className='radius-md cursor-pointer p-1 hover:bg-state-destructive-hover hover:text-text-destructive'
onMouseOver={() => setDestructive(true)}
onMouseOut={() => setDestructive(false)}
>
<RiDeleteBinLine className='h-4 w-4' onClick={() => onDelete(env)} />
</div>
</div>
<div className='system-xs-regular truncate text-text-tertiary'>{env.value_type === 'secret' ? envSecrets[env.id] : env.value}</div>
</div>
{env.description && (
<>
<div className={'h-[0.5px] bg-divider-subtle'} />
<div className={cn('rounded-bl-[8px] rounded-br-[8px] bg-background-default-subtle px-2.5 py-2 group-hover:bg-transparent',
destructive && 'bg-state-destructive-hover hover:bg-state-destructive-hover',
)}>
<div className='system-xs-regular truncate text-text-tertiary'>{env.description}</div>
</div>
</>
)}
<div className='system-xs-regular truncate text-text-tertiary'>{env.value_type === 'secret' ? envSecrets[env.id] : env.value}</div>
</div>
)
}

View File

@ -29,7 +29,6 @@ const VariableModal = ({
const [type, setType] = React.useState<'string' | 'number' | 'secret'>('string')
const [name, setName] = React.useState('')
const [value, setValue] = React.useState<any>()
const [description, setDescription] = React.useState<string>('')
const checkVariableName = (value: string) => {
const { isValid, errorMessageKey } = checkKeys([value], false)
@ -68,7 +67,6 @@ const VariableModal = ({
value_type: type,
name,
value: type === 'number' ? Number(value) : value,
description,
})
onClose()
}
@ -78,7 +76,6 @@ const VariableModal = ({
setType(env.value_type)
setName(env.name)
setValue(env.value_type === 'secret' ? envSecrets[env.id] : env.value)
setDescription(env.description)
}
}, [env, envSecrets])
@ -144,7 +141,7 @@ const VariableModal = ({
</div>
</div>
{/* value */}
<div className='mb-4'>
<div className=''>
<div className='system-sm-semibold mb-1 flex h-6 items-center text-text-secondary'>{t('workflow.env.modal.value')}</div>
<div className='flex'>
{
@ -163,18 +160,6 @@ const VariableModal = ({
}
</div>
</div>
{/* description */}
<div className=''>
<div className='system-sm-semibold mb-1 flex h-6 items-center text-text-secondary'>{t('workflow.env.modal.description')}</div>
<div className='flex'>
<textarea
className='system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs'
value={description}
placeholder={t('workflow.env.modal.descriptionPlaceholder') || ''}
onChange={e => setDescription(e.target.value)}
/>
</div>
</div>
</div>
<div className='flex flex-row-reverse rounded-b-2xl p-4 pt-2'>
<div className='flex gap-2'>

View File

@ -149,7 +149,6 @@ export type EnvironmentVariable = {
name: string
value: any
value_type: 'string' | 'number' | 'secret'
description: string
}
export type ConversationVariable = {

View File

@ -8,6 +8,8 @@ type IExplore = {
hasEditPermission: boolean
installedApps: InstalledApp[]
setInstalledApps: (installedApps: InstalledApp[]) => void
isFetchingInstalledApps: boolean
setIsFetchingInstalledApps: (isFetchingInstalledApps: boolean) => void
}
const ExploreContext = createContext<IExplore>({
@ -16,6 +18,8 @@ const ExploreContext = createContext<IExplore>({
hasEditPermission: false,
installedApps: [],
setInstalledApps: noop,
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: noop,
})
export default ExploreContext

View File

@ -7,15 +7,12 @@ import type { SystemFeatures } from '@/types/feature'
import { defaultSystemFeatures } from '@/types/feature'
import { getSystemFeatures } from '@/service/common'
import Loading from '@/app/components/base/loading'
import { AccessMode } from '@/models/access-control'
type GlobalPublicStore = {
isGlobalPending: boolean
setIsGlobalPending: (isPending: boolean) => void
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
webAppAccessMode: AccessMode,
setWebAppAccessMode: (webAppAccessMode: AccessMode) => void
}
export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
@ -23,8 +20,6 @@ export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })),
systemFeatures: defaultSystemFeatures,
setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })),
webAppAccessMode: AccessMode.PUBLIC,
setWebAppAccessMode: (webAppAccessMode: AccessMode) => set(() => ({ webAppAccessMode })),
}))
const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({

View File

@ -56,6 +56,7 @@ type ProviderContextState = {
}
},
refreshLicenseLimit: () => void
isAllowTransferWorkspace: boolean
}
const ProviderContext = createContext<ProviderContextState>({
modelProviders: [],
@ -97,6 +98,7 @@ const ProviderContext = createContext<ProviderContextState>({
},
},
refreshLicenseLimit: noop,
isAllowTransferWorkspace: false,
})
export const useProviderContext = () => useContext(ProviderContext)
@ -134,6 +136,7 @@ export const ProviderContextProvider = ({
const [enableEducationPlan, setEnableEducationPlan] = useState(false)
const [isEducationWorkspace, setIsEducationWorkspace] = useState(false)
const { data: isEducationAccount } = useEducationStatus(!enableEducationPlan)
const [isAllowTransferWorkspace, setIsAllowTransferWorkspace] = useState(false)
const fetchPlan = async () => {
try {
@ -162,6 +165,8 @@ export const ProviderContextProvider = ({
setWebappCopyrightEnabled(true)
if (data.workspace_members)
setLicenseLimit({ workspace_members: data.workspace_members })
if (data.is_allow_transfer_workspace)
setIsAllowTransferWorkspace(data.is_allow_transfer_workspace)
}
catch (error) {
console.error('Failed to fetch plan info:', error)
@ -222,6 +227,7 @@ export const ProviderContextProvider = ({
webappCopyrightEnabled,
licenseLimit,
refreshLicenseLimit: fetchPlan,
isAllowTransferWorkspace,
}}>
{children}
</ProviderContext.Provider>

View File

@ -0,0 +1,87 @@
'use client'
import type { ChatConfig } from '@/app/components/base/chat/types'
import Loading from '@/app/components/base/loading'
import { AccessMode } from '@/models/access-control'
import type { AppData, AppMeta } from '@/models/share'
import { useGetWebAppAccessModeByCode } from '@/service/use-share'
import { usePathname, useSearchParams } from 'next/navigation'
import type { FC, PropsWithChildren } from 'react'
import { useEffect } from 'react'
import { useState } from 'react'
import { create } from 'zustand'
type WebAppStore = {
shareCode: string | null
updateShareCode: (shareCode: string | null) => void
appInfo: AppData | null
updateAppInfo: (appInfo: AppData | null) => void
appParams: ChatConfig | null
updateAppParams: (appParams: ChatConfig | null) => void
webAppAccessMode: AccessMode
updateWebAppAccessMode: (accessMode: AccessMode) => void
appMeta: AppMeta | null
updateWebAppMeta: (appMeta: AppMeta | null) => void
userCanAccessApp: boolean
updateUserCanAccessApp: (canAccess: boolean) => void
}
export const useWebAppStore = create<WebAppStore>(set => ({
shareCode: null,
updateShareCode: (shareCode: string | null) => set(() => ({ shareCode })),
appInfo: null,
updateAppInfo: (appInfo: AppData | null) => set(() => ({ appInfo })),
appParams: null,
updateAppParams: (appParams: ChatConfig | null) => set(() => ({ appParams })),
webAppAccessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
updateWebAppAccessMode: (accessMode: AccessMode) => set(() => ({ webAppAccessMode: accessMode })),
appMeta: null,
updateWebAppMeta: (appMeta: AppMeta | null) => set(() => ({ appMeta })),
userCanAccessApp: false,
updateUserCanAccessApp: (canAccess: boolean) => set(() => ({ userCanAccessApp: canAccess })),
}))
const getShareCodeFromRedirectUrl = (redirectUrl: string | null): string | null => {
if (!redirectUrl || redirectUrl.length === 0)
return null
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
return url.pathname.split('/').pop() || null
}
const getShareCodeFromPathname = (pathname: string): string | null => {
const code = pathname.split('/').pop() || null
if (code === 'webapp-signin')
return null
return code
}
const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode)
const updateShareCode = useWebAppStore(state => state.updateShareCode)
const pathname = usePathname()
const searchParams = useSearchParams()
const redirectUrlParam = searchParams.get('redirect_url')
const [shareCode, setShareCode] = useState<string | null>(null)
useEffect(() => {
const shareCodeFromRedirect = getShareCodeFromRedirectUrl(redirectUrlParam)
const shareCodeFromPathname = getShareCodeFromPathname(pathname)
const newShareCode = shareCodeFromRedirect || shareCodeFromPathname
setShareCode(newShareCode)
updateShareCode(newShareCode)
}, [pathname, redirectUrlParam, updateShareCode])
const { isFetching, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode)
useEffect(() => {
if (accessModeResult?.accessMode)
updateWebAppAccessMode(accessModeResult.accessMode)
}, [accessModeResult, updateWebAppAccessMode])
if (isFetching) {
return <div className='flex h-full w-full items-center justify-center'>
<Loading />
</div>
}
return (
<>
{children}
</>
)
}
export default WebAppStoreProvider

View File

@ -129,8 +129,6 @@ const translation = {
value: 'Wert',
valuePlaceholder: 'Umgebungswert',
secretTip: 'Wird verwendet, um sensible Informationen oder Daten zu definieren, wobei DSL-Einstellungen zur Verhinderung von Lecks konfiguriert sind.',
description: 'Beschreibung',
descriptionPlaceholder: 'Beschreiben Sie die Variable',
},
export: {
title: 'Geheime Umgebungsvariablen exportieren?',
@ -539,10 +537,6 @@ const translation = {
title: 'Importieren von cURL',
placeholder: 'Fügen Sie hier die cURL-Zeichenfolge ein',
},
verifySSL: {
title: 'SSL-Zertifikat überprüfen',
warningTooltip: 'Das Deaktivieren der SSL-Überprüfung wird für Produktionsumgebungen nicht empfohlen. Dies sollte nur in der Entwicklung oder im Test verwendet werden, da es die Verbindung anfällig für Sicherheitsbedrohungen wie Man-in-the-Middle-Angriffe macht.',
},
},
code: {
inputVars: 'Eingabevariablen',

View File

@ -240,13 +240,13 @@ const translation = {
verifyNew: 'Verify your new email',
authTip: 'Once your email is changed, Google or GitHub accounts linked to your old email will no longer be able to log in to this account.',
content1: 'If you continue, we\'ll send a verification code to <email>{{email}}</email> for re-authentication.',
content2: 'Your current email is <email>{{email}}</email>. Verification code has been sent to this email address.',
content2: 'Your current email is <email>{{email}}</email> . Verification code has been sent to this email address.',
content3: 'Enter a new email and we will send you a verification code.',
content4: 'We just sent you a temporary verification code to <email>{{email}}</email>.',
codeLabel: 'Verification code',
codePlaceholder: 'Paste the 6-digit code',
emailLabel: 'New email',
emailPlaceholder: 'Enter a new email',
emailPlaceholder: 'Enter new email',
existingEmail: 'A user with this email already exists.',
sendVerifyCode: 'Send verification code',
continue: 'Continue',
@ -296,6 +296,26 @@ const translation = {
disInvite: 'Cancel the invitation',
deleteMember: 'Delete Member',
you: '(You)',
transferOwnership: 'Transfer Ownership',
transferModal: {
title: 'Transfer workspace ownership',
warning: 'You\'re about to transfer ownership of “{{workspace}}”. This takes effect immediately and can\'t be undone.',
warningTip: 'You\'ll become an admin member, and the new owner will have full control.',
sendTip: 'If you continue, we\'ll send a verification code to <email>{{email}}</email> for re-authentication.',
verifyEmail: 'Verify your email',
verifyContent: 'Your current email is <email>{{email}}</email>.',
verifyContent2: 'We\'ll send a temporary verification code to this email for re-authentication.',
codeLabel: 'Verification code',
codePlaceholder: 'Paste the 6-digit code',
resendTip: 'Didn\'t receive a code?',
resendCount: 'Resend in {{count}}s',
resend: 'Resend',
transferLabel: 'Transfer workspace ownership to',
transferPlaceholder: 'Select a workspace member…',
sendVerifyCode: 'Send verification code',
continue: 'Continue',
transfer: 'Transfer workspace ownership',
},
},
integrations: {
connected: 'Connected',

View File

@ -105,6 +105,7 @@ const translation = {
licenseInactive: 'License Inactive',
licenseInactiveTip: 'The Dify Enterprise license for your workspace is inactive. Please contact your administrator to continue using Dify.',
webapp: {
login: 'Login',
noLoginMethod: 'Authentication method not configured for web app',
noLoginMethodTip: 'Please contact the system admin to add an authentication method.',
disabled: 'Webapp authentication is disabled. Please contact the system admin to enable it. You can try to use the app directly.',

View File

@ -127,8 +127,6 @@ const translation = {
value: 'Value',
valuePlaceholder: 'env value',
secretTip: 'Used to define sensitive information or data, with DSL settings configured for leak prevention.',
description: 'Description',
descriptionPlaceholder: 'Describe the variable',
},
export: {
title: 'Export Secret environment variables?',
@ -544,10 +542,6 @@ const translation = {
title: 'Import from cURL',
placeholder: 'Paste cURL string here',
},
verifySSL: {
title: 'Verify SSL Certificate',
warningTooltip: 'Disabling SSL verification is not recommended for production environments. This should only be used in development or testing, as it makes the connection vulnerable to security threats like man-in-the-middle attacks.',
},
},
code: {
inputVars: 'Input Variables',

View File

@ -129,8 +129,6 @@ const translation = {
value: 'Valor',
valuePlaceholder: 'valor de env',
secretTip: 'Se utiliza para definir información o datos sensibles, con configuraciones DSL configuradas para prevenir fugas.',
description: 'Descripción',
descriptionPlaceholder: 'Describa la variable',
},
export: {
title: '¿Exportar variables de entorno secretas?',
@ -537,10 +535,6 @@ const translation = {
title: 'Importar desde cURL',
placeholder: 'Pegar la cadena cURL aquí',
},
verifySSL: {
title: 'Verificar el certificado SSL',
warningTooltip: 'Deshabilitar la verificación SSL no se recomienda para entornos de producción. Esto solo debe utilizarse en desarrollo o pruebas, ya que hace que la conexión sea vulnerable a amenazas de seguridad como ataques de intermediario.',
},
},
code: {
inputVars: 'Variables de entrada',

View File

@ -129,8 +129,6 @@ const translation = {
value: 'مقدار',
valuePlaceholder: 'مقدار متغیر',
secretTip: 'برای تعریف اطلاعات حساس یا داده‌ها، با تنظیمات DSL برای جلوگیری از نشت پیکربندی شده است.',
description: 'توضیحات',
descriptionPlaceholder: 'متغیر را توصیف کنید',
},
export: {
title: 'آیا متغیرهای محیطی مخفی را صادر کنید؟',
@ -539,10 +537,6 @@ const translation = {
title: 'وارد کردن از cURL',
placeholder: 'رشته cURL را اینجا بچسبانید',
},
verifySSL: {
title: 'گواهی SSL را تأیید کنید',
warningTooltip: 'غیرفعال کردن تأیید SSL برای محیط‌های تولید توصیه نمی‌شود. این فقط باید در توسعه یا آزمایش استفاده شود، زیرا این کار اتصال را در معرض تهدیدات امنیتی مانند حملات میانی قرار می‌دهد.',
},
},
code: {
inputVars: 'متغیرهای ورودی',

View File

@ -129,8 +129,6 @@ const translation = {
value: 'valeur',
valuePlaceholder: 'Valeur de l\'env',
secretTip: 'Utilisé pour définir des informations ou des données sensibles, avec des paramètres DSL configurés pour la prévention des fuites.',
description: 'Description',
descriptionPlaceholder: 'Décrivez la variable',
},
export: {
title: 'Exporter des variables d\'environnement secrètes?',
@ -539,10 +537,6 @@ const translation = {
placeholder: 'Collez la chaîne cURL ici',
title: 'Importer à partir de cURL',
},
verifySSL: {
title: 'Vérifier le certificat SSL',
warningTooltip: 'Désactiver la vérification SSL n\'est pas recommandé pour les environnements de production. Cela ne devrait être utilisé que dans le développement ou les tests, car cela rend la connexion vulnérable aux menaces de sécurité telles que les attaques de type \'man-in-the-middle\'.',
},
},
code: {
inputVars: 'Variables de saisie',

View File

@ -132,8 +132,6 @@ const translation = {
value: 'मान',
valuePlaceholder: 'पर्यावरण मान',
secretTip: 'संवेदनशील जानकारी या डेटा को परिभाषित करने के लिए उपयोग किया जाता है, DSL सेटिंग्स लीक रोकथाम के लिए कॉन्फ़िगर की गई हैं।',
description: 'विवरण',
descriptionPlaceholder: 'चर का वर्णन करें',
},
export: {
title: 'गुप्त पर्यावरण चर निर्यात करें?',
@ -552,10 +550,6 @@ const translation = {
placeholder: 'यहां cURL स्ट्रिंग पेस्ट करें',
title: 'cURL से आयात करें',
},
verifySSL: {
title: 'SSL प्रमाणपत्र की पुष्टि करें',
warningTooltip: 'SSL सत्यापन को अक्षम करना उत्पादन वातावरण के लिए अनुशंसित नहीं है। इसका उपयोग केवल विकास या परीक्षण में किया जाना चाहिए, क्योंकि यह कनेक्शन को मिडल-मैन हमलों जैसे सुरक्षा खतरों के लिए कमजोर बना देता है।',
},
},
code: {
inputVars: 'इनपुट वेरिएबल्स',

View File

@ -133,8 +133,6 @@ const translation = {
value: 'Valore',
valuePlaceholder: 'valore env',
secretTip: 'Utilizzato per definire informazioni o dati sensibili, con impostazioni DSL configurate per la prevenzione delle fughe.',
description: 'Descrizione',
descriptionPlaceholder: 'Descrivi la variabile',
},
export: {
title: 'Esportare variabili d\'ambiente segrete?',
@ -555,10 +553,6 @@ const translation = {
placeholder: 'Incolla qui la stringa cURL',
title: 'Importazione da cURL',
},
verifySSL: {
title: 'Verifica il certificato SSL',
warningTooltip: 'Disabilitare la verifica SSL non è raccomandato per gli ambienti di produzione. Questo dovrebbe essere utilizzato solo in sviluppo o test, poiché rende la connessione vulnerabile a minacce alla sicurezza come gli attacchi man-in-the-middle.',
},
},
code: {
inputVars: 'Variabili di Input',

View File

@ -234,28 +234,6 @@ const translation = {
editWorkspaceInfo: 'ワークスペース情報を編集',
workspaceName: 'ワークスペース名',
workspaceIcon: 'ワークスペースアイコン',
changeEmail: {
title: 'メールアドレスを変更',
verifyEmail: '現在のメールアドレスを確認してください',
newEmail: '新しいメールアドレスを設定する',
verifyNew: '新しいメールアドレスを確認してください',
authTip: 'メールアドレスが変更されると、旧メールアドレスにリンクされている Google または GitHub アカウントは、このアカウントにログインできなくなります。',
content1: '変更を続ける場合、<email>{{email}}</email> に認証用の確認コードをお送りします。',
content2: '現在のメールアドレスは <email>{{email}}</email> です。認証コードはこのメールアドレスに送信されました。',
content3: '新しいメールアドレスを入力すると、確認コードが送信されます。',
content4: '一時確認コードを <email>{{email}}</email> に送信しました。',
codeLabel: 'コード',
codePlaceholder: 'コードを入力してください',
emailLabel: '新しいメール',
emailPlaceholder: '新しいメールを入力してください',
existingEmail: 'このメールアドレスのユーザーは既に存在します',
sendVerifyCode: '確認コードを送信',
continue: '続行',
changeTo: '{{email}} に変更',
resendTip: 'コードが届きませんか?',
resendCount: '{{count}} 秒後に再送信',
resend: '再送信',
},
},
members: {
team: 'チーム',

View File

@ -106,6 +106,7 @@ const translation = {
licenseExpired: 'ライセンスの有効期限が切れています',
licenseLostTip: 'Dify ライセンスサーバーへの接続に失敗しました。続けて Dify を使用するために管理者に連絡してください。',
webapp: {
login: 'ログイン',
noLoginMethod: 'Web アプリに対して認証方法が構成されていません',
noLoginMethodTip: 'システム管理者に連絡して、認証方法を追加してください。',
disabled: 'Web アプリの認証が無効になっています。システム管理者に連絡して有効にしてください。直接アプリを使用してみてください。',

View File

@ -127,8 +127,6 @@ const translation = {
value: '値',
valuePlaceholder: '変数値を入力',
secretTip: 'この変数は機密情報やデータを定義するために使用されます。DSL をエクスポートするときに漏洩防止メカニズムを設定されます。',
description: '説明',
descriptionPlaceholder: '変数の説明を入力',
},
export: {
title: 'シークレット環境変数をエクスポートしますか?',
@ -545,10 +543,6 @@ const translation = {
title: 'cURL からインポート',
placeholder: 'ここに cURL 文字列を貼り付けます',
},
verifySSL: {
title: 'SSL証明書を確認する',
warningTooltip: 'SSL検証を無効にすることは、本番環境では推奨されません。これは開発またはテストのみに使用すべきであり、中間者攻撃などのセキュリティ脅威に対して接続を脆弱にするためです。',
},
},
code: {
inputVars: '入力変数',

View File

@ -135,8 +135,6 @@ const translation = {
valuePlaceholder: '환경 값',
secretTip:
'민감한 정보나 데이터를 정의하는 데 사용되며, DSL 설정은 유출 방지를 위해 구성됩니다.',
description: '설명',
descriptionPlaceholder: '변수에 대해 설명하세요',
},
export: {
title: '비밀 환경 변수를 내보내시겠습니까?',
@ -567,10 +565,6 @@ const translation = {
title: 'cURL 에서 가져오기',
placeholder: '여기에 cURL 문자열 붙여 넣기',
},
verifySSL: {
title: 'SSL 인증서 확인',
warningTooltip: 'SSL 검증을 비활성화하는 것은 프로덕션 환경에서는 권장되지 않습니다. 이는 연결이 중간자 공격과 같은 보안 위협에 취약하게 만들므로 개발 또는 테스트에서만 사용해야 합니다.',
},
},
code: {
inputVars: '입력 변수',

View File

@ -129,8 +129,6 @@ const translation = {
value: 'Wartość',
valuePlaceholder: 'wartość środowiska',
secretTip: 'Używane do definiowania wrażliwych informacji lub danych, z ustawieniami DSL skonfigurowanymi do zapobiegania wyciekom.',
description: 'Opis',
descriptionPlaceholder: 'Opisz zmienną',
},
export: {
title: 'Eksportować tajne zmienne środowiskowe?',
@ -539,10 +537,6 @@ const translation = {
placeholder: 'Wklej tutaj ciąg cURL',
title: 'Importowanie z cURL',
},
verifySSL: {
title: 'Zweryfikuj certyfikat SSL',
warningTooltip: 'Wyłączenie weryfikacji SSL nie jest zalecane w środowiskach produkcyjnych. Powinno to być używane tylko w rozwoju lub testowaniu, ponieważ naraża połączenie na zagrożenia bezpieczeństwa, takie jak ataki typu man-in-the-middle.',
},
},
code: {
inputVars: 'Zmienne wejściowe',

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