Feat/change user email (#22425)

Co-authored-by: JzoNg <jzongcode@gmail.com>
deploy/enterprise
zyssyz123 7 months ago committed by GitHub
parent 0fe9d4955c
commit 3484cee5cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,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>

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

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

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

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

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

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

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

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Arial', sans-serif;
line-height: 16pt;
color: #101828;
background-color: #e9ebf0;
margin: 0;
padding: 0;
}
.container {
width: 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>

@ -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">验证您的工作空间所有权转移请求</p>
<p class="description">我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移的请求。
为了确认此操作,请使用以下验证码。
此验证码仅在5分钟内有效</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">请注意:所有权转移一旦确认将立即生效且无法撤销。您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。
如果您没有发起此请求,请忽略此邮件或立即联系客服。
</p>
</div>
</body>
</html>

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

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

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

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

@ -0,0 +1,82 @@
<!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>

@ -0,0 +1,82 @@
<!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>

@ -0,0 +1,84 @@
<!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>

@ -0,0 +1,82 @@
<!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>

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

@ -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">验证您的工作空间所有权转移请求</p>
<p class="description">我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移的请求。
为了确认此操作,请使用以下验证码。
此验证码仅在5分钟内有效</p>
<div class="code-content">
<span class="code">{{code}}</span>
</div>
<p class="tips">请注意:所有权转移一旦确认将立即生效且无法撤销。
您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。如果您没有发起此请求,请忽略此邮件或立即联系客服。
</p>
</div>
</body>
</html>

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

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

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

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

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

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

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

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

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

@ -0,0 +1,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

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

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

@ -233,6 +233,28 @@ const translation = {
editWorkspaceInfo: 'Edit Workspace Info',
workspaceName: 'Workspace Name',
workspaceIcon: 'Workspace Icon',
changeEmail: {
title: 'Change Email',
verifyEmail: 'Verify your current email',
newEmail: 'Set up a new email address',
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.',
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 new email',
existingEmail: 'A user with this email already exists.',
sendVerifyCode: 'Send verification code',
continue: 'Continue',
changeTo: 'Change to {{email}}',
resendTip: 'Didn\'t receive a code?',
resendCount: 'Resend in {{count}}s',
resend: 'Resend',
},
},
members: {
team: 'Team',
@ -274,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',

@ -234,6 +234,28 @@ 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: 'チーム',
@ -275,6 +297,26 @@ const translation = {
disInvite: '招待をキャンセル',
deleteMember: 'メンバーを削除',
you: '(あなた)',
transferOwnership: '所有権の移転',
transferModal: {
title: 'ワークスペースの所有権を移する',
warning: '「{{workspace}}」の所有権を移しようとしています。この操作は即時に有効となり、元に戻すことはできません。',
warningTip: 'あなたは管理者メンバーになり、新しいオーナーがすべての権限を持つことになります。',
sendTip: '続行する場合は、本人確認のため <email>{{email}}</email> に認証コードを送信します。',
verifyEmail: '現在のメールアドレスを確認',
verifyContent: '現在のメールアドレスは <email>{{email}}</email>。',
verifyContent2: 'このメールアドレスに一時的な認証コードを送信し、再認証を行います。',
codeLabel: '認証コード',
codePlaceholder: '6 桁のコードを入力してください',
resendTip: '認証コードを受け取れない場合は、',
resendCount: '{{count}} 秒後に再送信',
resend: '認証コードを再送信',
transferLabel: 'ワークスペースの所有権を転移する相手は',
transferPlaceholder: 'メールアドレスを入力してください',
sendVerifyCode: '認証コードを送信',
continue: '続行する',
transfer: 'ワークスペースの所有権を移する',
},
},
integrations: {
connected: '接続済み',

@ -233,6 +233,28 @@ 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: '输入 6 位数字验证码',
emailLabel: '新邮箱',
emailPlaceholder: '输入新邮箱',
existingEmail: '该邮箱已存在',
sendVerifyCode: '发送验证码',
continue: '继续',
changeTo: '更改为 {{email}}',
resendTip: '没有收到验证码?',
resendCount: '请在 {{count}} 秒后重新发送',
resend: '重新发送',
},
},
members: {
team: '团队',
@ -274,6 +296,26 @@ const translation = {
builderTip: '可以构建和编辑自己的应用程序',
setBuilder: 'Set as builder设置为构建器',
builder: '构建器',
transferOwnership: '转移所有权',
transferModal: {
title: '转移工作空间所有权',
warning: '您即将转让“{{workspace}}”的所有权。此操作立即生效,且无法撤消。',
warningTip: '您将成为管理员成员,新所有者将拥有完全控制权。',
sendTip: '如果您继续,我们将向 <email>{{email}}</email> 发送验证码以进行重新验证。',
verifyEmail: '验证你的邮箱',
verifyContent: '您当前的电子邮件地址是 <email>{{email}}</email>。',
verifyContent2: '我们将向此电子邮件发送临时验证码,以便重新进行身份验证。',
codeLabel: '验证码',
codePlaceholder: '输入 6 位数字验证码',
resendTip: '没有收到验证码?',
resendCount: '请在 {{count}} 秒后重新发送',
resend: '重新发送',
transferLabel: '新所有者',
transferPlaceholder: '选择一个成员',
sendVerifyCode: '发送验证码',
continue: '继续',
transfer: '转移工作空间所有权',
},
},
integrations: {
connected: '登录方式',

@ -131,6 +131,15 @@ export const deleteMemberOrCancelInvitation: Fetcher<CommonResponse, { url: stri
return del<CommonResponse>(url)
}
export const sendOwnerEmail = (body: { language?: string }) =>
post<CommonResponse & { data: string }>('/workspaces/current/members/send-owner-transfer-confirm-email', { body })
export const verifyOwnerEmail = (body: { code: string; token: string }) =>
post<CommonResponse & { is_valid: boolean; email: string; token: string }>('/workspaces/current/members/owner-transfer-check', { body })
export const ownershipTransfer = (memberID: string, body: { token: string }) =>
post<CommonResponse & { is_valid: boolean; email: string; token: string }>(`/workspaces/current/members/${memberID}/owner-transfer`, { body })
export const fetchFilePreview: Fetcher<{ content: string }, { fileID: string }> = ({ fileID }) => {
return get<{ content: string }>(`/files/${fileID}/preview`)
}
@ -376,3 +385,15 @@ export const submitDeleteAccountFeedback = (body: { feedback: string; email: str
export const getDocDownloadUrl = (doc_name: string) =>
get<{ url: string }>('/compliance/download', { params: { doc_name } }, { silent: true })
export const sendVerifyCode = (body: { email: string; phase: string; token?: string }) =>
post<CommonResponse & { data: string }>('/account/change-email', { body })
export const verifyEmail = (body: { email: string; code: string; token: string }) =>
post<CommonResponse & { is_valid: boolean; email: string; token: string }>('/account/change-email/validity', { body })
export const resetEmail = (body: { new_email: string; token: string }) =>
post<CommonResponse>('/account/change-email/reset', { body })
export const checkEmailExisted = (body: { email: string }) =>
post<CommonResponse>('/account/change-email/check-email-unique', { body })

@ -35,6 +35,7 @@ export type SystemFeatures = {
sso_enforced_for_web: boolean
sso_enforced_for_web_protocol: SSOProtocol | ''
enable_marketplace: boolean
enable_change_email: boolean
enable_email_code_login: boolean
enable_email_password_login: boolean
enable_social_oauth_login: boolean
@ -70,6 +71,7 @@ export const defaultSystemFeatures: SystemFeatures = {
sso_enforced_for_web: false,
sso_enforced_for_web_protocol: '',
enable_marketplace: false,
enable_change_email: false,
enable_email_code_login: false,
enable_email_password_login: false,
enable_social_oauth_login: false,

Loading…
Cancel
Save