diff --git a/api/.env.example b/api/.env.example index cd05b124e3..7b08c032ed 100644 --- a/api/.env.example +++ b/api/.env.example @@ -483,6 +483,7 @@ 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 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index d5676efdf8..2fd9f94e06 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -36,6 +36,11 @@ class SecurityConfig(BaseSettings): 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", default=False, @@ -596,6 +601,11 @@ class AuthConfig(BaseSettings): 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): """ diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index 7076c850a8..f4a8b97483 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -37,6 +37,12 @@ class EmailChangeRateLimitExceededError(BaseHTTPException): 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." @@ -83,3 +89,27 @@ 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 diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 48225ac90d..ec44300559 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -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,150 @@ 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/") api.add_resource(MemberUpdateRoleApi, "/workspaces/current/members//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//owner-transfer") diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index feb4b2ec39..d862dac373 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -248,3 +248,16 @@ def enable_change_email(view): 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 diff --git a/api/services/account_service.py b/api/services/account_service.py index 9f790075a6..4d5366f47f 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -55,6 +55,11 @@ from tasks.mail_account_deletion_task import send_account_deletion_verification_ 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 @@ -77,9 +82,12 @@ class AccountService: 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: @@ -451,6 +459,72 @@ class AccountService: 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, @@ -485,6 +559,22 @@ class AccountService: ) 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") @@ -493,6 +583,10 @@ class AccountService: 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") @@ -501,6 +595,10 @@ class AccountService: 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" @@ -638,6 +736,34 @@ class AccountService: key = f"change_email_error_rate_limit:{email}" redis_client.delete(key) + @staticmethod + @redis_fallback(default_return=None) + def add_owner_transfer_error_rate_limit(email: str) -> None: + key = f"owner_transfer_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + count = 0 + count = int(count) + 1 + redis_client.setex(key, dify_config.OWNER_TRANSFER_LOCKOUT_DURATION, count) + + @staticmethod + @redis_fallback(default_return=False) + def is_owner_transfer_error_rate_limit(email: str) -> bool: + key = f"owner_transfer_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + return False + count = int(count) + if count > AccountService.OWNER_TRANSFER_MAX_ERROR_LIMITS: + return True + return False + + @staticmethod + @redis_fallback(default_return=None) + def reset_owner_transfer_error_rate_limit(email: str): + key = f"owner_transfer_error_rate_limit:{email}" + redis_client.delete(key) + @staticmethod @redis_fallback(default_return=False) def is_email_send_ip_limit(ip_address: str): @@ -955,6 +1081,15 @@ class TenantService: return cast(dict, tenant.custom_config_dict) + @staticmethod + def is_owner(account: Account, tenant: Tenant) -> bool: + return TenantService.get_user_role(account, tenant) == TenantAccountRole.OWNER + + @staticmethod + def is_member(account: Account, tenant: Tenant) -> bool: + """Check if the account is a member of the tenant""" + return TenantService.get_user_role(account, tenant) is not None + class RegisterService: @classmethod diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 3f164431fb..1441e6ce16 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -123,6 +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=()) @@ -229,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"] diff --git a/api/tasks/mail_owner_transfer_task.py b/api/tasks/mail_owner_transfer_task.py new file mode 100644 index 0000000000..8d05c6dc0f --- /dev/null +++ b/api/tasks/mail_owner_transfer_task.py @@ -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)) diff --git a/api/templates/transfer_workspace_new_owner_notify_template_en-US.html b/api/templates/transfer_workspace_new_owner_notify_template_en-US.html new file mode 100644 index 0000000000..1d546174b1 --- /dev/null +++ b/api/templates/transfer_workspace_new_owner_notify_template_en-US.html @@ -0,0 +1,86 @@ + + + + + + + + +
+
+ + Dify Logo +
+

You are now the owner of {{WorkspaceName}}

+

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.

+ +
+ + + \ No newline at end of file diff --git a/api/templates/transfer_workspace_new_owner_notify_template_zh-CN.html b/api/templates/transfer_workspace_new_owner_notify_template_zh-CN.html new file mode 100644 index 0000000000..ccb52f26c5 --- /dev/null +++ b/api/templates/transfer_workspace_new_owner_notify_template_zh-CN.html @@ -0,0 +1,86 @@ + + + + + + + + +
+
+ + Dify Logo +
+

您现在是 {{WorkspaceName}} 的所有者

+

您已被分配为工作空间“{{WorkspaceName}}”的新所有者。 + + 作为新所有者,您现在对该工作空间拥有完全的管理权限。 + + 如果您有任何问题,请联系support@dify.ai。

+ +
+ + + \ No newline at end of file diff --git a/api/templates/transfer_workspace_old_owner_notify_template_en-US.html b/api/templates/transfer_workspace_old_owner_notify_template_en-US.html new file mode 100644 index 0000000000..24be72f89c --- /dev/null +++ b/api/templates/transfer_workspace_old_owner_notify_template_en-US.html @@ -0,0 +1,88 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Workspace ownership has been transferred

+

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.

+ +
+ + + \ No newline at end of file diff --git a/api/templates/transfer_workspace_old_owner_notify_template_zh-CN.html b/api/templates/transfer_workspace_old_owner_notify_template_zh-CN.html new file mode 100644 index 0000000000..31a5b36fc9 --- /dev/null +++ b/api/templates/transfer_workspace_old_owner_notify_template_zh-CN.html @@ -0,0 +1,86 @@ + + + + + + + + +
+
+ + Dify Logo +
+

工作区所有权已转移

+

您已成功将工作空间“{{WorkspaceName}}”的所有权转移给{{NewOwnerEmail}}。 + + 您不再拥有此工作空间的拥有者权限。您的访问级别已更改为管理员。 + + 如果您没有发起此转移或对此变更有任何疑问,请立即联系support@dify.ai。

+ +
+ + + \ No newline at end of file diff --git a/api/templates/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/transfer_workspace_owner_confirm_template_en-US.html new file mode 100644 index 0000000000..46538325d4 --- /dev/null +++ b/api/templates/transfer_workspace_owner_confirm_template_en-US.html @@ -0,0 +1,91 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Verify Your Request to Transfer Workspace Ownership

+

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:

+
+ {{code}} +
+

Please note: The ownership transfer will take effect immediately once confirmed and cannot be + undone. + You’ll become a admin member, and the new owner will have full control of the workspace.If you didn’t make this + request, please ignore this email or contact support immediately.

+
+ + + \ No newline at end of file diff --git a/api/templates/transfer_workspace_owner_confirm_template_zh-CN.html b/api/templates/transfer_workspace_owner_confirm_template_zh-CN.html new file mode 100644 index 0000000000..942667daea --- /dev/null +++ b/api/templates/transfer_workspace_owner_confirm_template_zh-CN.html @@ -0,0 +1,90 @@ + + + + + + + + +
+
+ + Dify Logo +
+

验证您的工作空间所有权转移请求

+

我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移的请求。 + + 为了确认此操作,请使用以下验证码。 + 此验证码仅在5分钟内有效:

+
+ {{code}} +
+

请注意:所有权转移一旦确认将立即生效且无法撤销。您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。 + 如果您没有发起此请求,请忽略此邮件或立即联系客服。 +

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/transfer_workspace_new_owner_notify_template_en-US.html b/api/templates/without-brand/transfer_workspace_new_owner_notify_template_en-US.html new file mode 100644 index 0000000000..f131098093 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_new_owner_notify_template_en-US.html @@ -0,0 +1,81 @@ + + + + + + + + +
+

You are now the owner of {{WorkspaceName}}

+

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.

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html b/api/templates/without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html new file mode 100644 index 0000000000..f0fc9ade3d --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html @@ -0,0 +1,81 @@ + + + + + + + + +
+

您现在是 {{WorkspaceName}} 的所有者

+

您已被分配为工作空间“{{WorkspaceName}}”的新所有者。 + + 作为新所有者,您现在对该工作空间拥有完全的管理权限。 + + 如果您有任何问题,请联系support@dify.ai。

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/transfer_workspace_old_owner_notify_template_en-US.html b/api/templates/without-brand/transfer_workspace_old_owner_notify_template_en-US.html new file mode 100644 index 0000000000..9ea3844c0e --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_old_owner_notify_template_en-US.html @@ -0,0 +1,83 @@ + + + + + + + + +
+

Workspace ownership has been transferred

+

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.

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html b/api/templates/without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html new file mode 100644 index 0000000000..093865d2e5 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html @@ -0,0 +1,81 @@ + + + + + + + + +
+

工作区所有权已转移

+

您已成功将工作空间“{{WorkspaceName}}”的所有权转移给{{NewOwnerEmail}}。 + + 您不再拥有此工作空间的拥有者权限。您的访问级别已更改为管理员。 + + 如果您没有发起此转移或对此变更有任何疑问,请立即联系support@dify.ai。

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html new file mode 100644 index 0000000000..cd8762e4f7 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html @@ -0,0 +1,88 @@ + + + + + + + + +
+

Verify Your Request to Transfer Workspace Ownership

+

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:

+
+ {{code}} +
+

Please note: The ownership transfer will take effect immediately once confirmed and cannot be + undone. + You’ll become a admin member, and the new owner will have full control of the workspace.If you didn’t make + this + request, please ignore this email or contact support immediately.

+
+ + + \ No newline at end of file diff --git a/api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html b/api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html new file mode 100644 index 0000000000..fbcb600083 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html @@ -0,0 +1,87 @@ + + + + + + + + +
+

验证您的工作空间所有权转移请求

+

我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移的请求。 + + 为了确认此操作,请使用以下验证码。 + 此验证码仅在5分钟内有效:

+
+ {{code}} +
+

请注意:所有权转移一旦确认将立即生效且无法撤销。 + + 您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。如果您没有发起此请求,请忽略此邮件或立即联系客服。 +

+
+ + + \ No newline at end of file diff --git a/api/tests/integration_tests/.env.example b/api/tests/integration_tests/.env.example index 49e01140b5..2e98dec964 100644 --- a/api/tests/integration_tests/.env.example +++ b/api/tests/integration_tests/.env.example @@ -204,6 +204,7 @@ 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 diff --git a/docker/.env.example b/docker/.env.example index 376fd573c3..e2d7436067 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -769,6 +769,7 @@ 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 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index a7b8935e8c..3803c26a33 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -334,6 +334,7 @@ x-shared-env: &shared-api-worker-env 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}