diff --git a/api/.env.example b/api/.env.example index eab017a624..c2d8c78a7d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index f6a8b037ca..f1d529355d 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -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): """ diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index b40934dbf5..f4a8b97483 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -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 diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index a9dbf44456..913f820b59 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -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') diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 48225ac90d..30a4148dbb 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -1,22 +1,34 @@ from urllib import parse +from flask import request from flask_login import current_user from flask_restful import Resource, abort, marshal_with, reqparse import services from configs import dify_config from controllers.console import api -from controllers.console.error import WorkspaceMembersLimitExceeded +from controllers.console.auth.error import ( + CannotTransferOwnerToSelfError, + EmailCodeError, + InvalidEmailError, + InvalidTokenError, + MemberNotInTenantError, + NotOwnerError, + OwnerTransferLimitError, +) +from controllers.console.error import EmailSendIpLimitError, WorkspaceMembersLimitExceeded from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, + is_allow_transfer_owner, setup_required, ) from extensions.ext_database import db from fields.member_fields import account_with_role_list_fields +from libs.helper import extract_remote_ip from libs.login import login_required from models.account import Account, TenantAccountRole -from services.account_service import RegisterService, TenantService +from services.account_service import AccountService, RegisterService, TenantService from services.errors.account import AccountAlreadyInTenantError from services.feature_service import FeatureService @@ -156,8 +168,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/") api.add_resource(MemberUpdateRoleApi, "/workspaces/current/members//update-role") api.add_resource(DatasetOperatorMemberListApi, "/workspaces/current/dataset-operators") +# owner transfer +api.add_resource(SendOwnerTransferEmailApi, "/workspaces/current/members/send-owner-transfer-confirm-email") +api.add_resource(OwnerTransferCheckApi, "/workspaces/current/members/owner-transfer-check") +api.add_resource(OwnerTransfer, "/workspaces/current/members//owner-transfer") diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index ca122772de..d862dac373 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -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 diff --git a/api/services/account_service.py b/api/services/account_service.py index 2ba6f4345b..4d5366f47f 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -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 diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 188caf3505..1441e6ce16 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -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"] diff --git a/api/tasks/mail_change_mail_task.py b/api/tasks/mail_change_mail_task.py new file mode 100644 index 0000000000..da44040b7d --- /dev/null +++ b/api/tasks/mail_change_mail_task.py @@ -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)) diff --git a/api/tasks/mail_owner_transfer_task.py b/api/tasks/mail_owner_transfer_task.py new file mode 100644 index 0000000000..8d05c6dc0f --- /dev/null +++ b/api/tasks/mail_owner_transfer_task.py @@ -0,0 +1,152 @@ +import logging +import time + +import click +from celery import shared_task # type: ignore +from flask import render_template + +from extensions.ext_mail import mail +from services.feature_service import FeatureService + + +@shared_task(queue="mail") +def send_owner_transfer_confirm_task(language: str, to: str, code: str, workspace: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param workspace: Workspace name + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_owner_confirm_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_owner_confirm_template_zh-CN.html" + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) + else: + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) + else: + template = "transfer_workspace_owner_confirm_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_owner_confirm_template_en-US.html" + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content) + else: + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), + fg="green", + ) + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) + + +@shared_task(queue="mail") +def send_old_owner_transfer_notify_email_task(language: str, to: str, workspace: str, new_owner_email: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param workspace: Workspace name + :param new_owner_email: New owner email + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_old_owner_notify_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html" + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="工作区所有权已转移", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="工作区所有权已转移", html=html_content) + else: + template = "transfer_workspace_old_owner_notify_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_old_owner_notify_template_en-US.html" + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), + fg="green", + ) + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) + + +@shared_task(queue="mail") +def send_new_owner_transfer_notify_email_task(language: str, to: str, workspace: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param code: Change email code + :param workspace: Workspace name + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_new_owner_notify_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html" + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content) + else: + template = "transfer_workspace_new_owner_notify_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_new_owner_notify_template_en-US.html" + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), + fg="green", + ) + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) diff --git a/api/templates/change_mail_confirm_new_template_en-US.html b/api/templates/change_mail_confirm_new_template_en-US.html new file mode 100644 index 0000000000..7e2ccca493 --- /dev/null +++ b/api/templates/change_mail_confirm_new_template_en-US.html @@ -0,0 +1,89 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Confirm Your New Email Address

+

You’re 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:

+
+ {{code}} +
+

If you didn’t make this request, please ignore this email or contact support immediately.

+
+ + + + diff --git a/api/templates/change_mail_confirm_new_template_zh-CN.html b/api/templates/change_mail_confirm_new_template_zh-CN.html new file mode 100644 index 0000000000..ccaaf60255 --- /dev/null +++ b/api/templates/change_mail_confirm_new_template_zh-CN.html @@ -0,0 +1,89 @@ + + + + + + + + +
+
+ + Dify Logo +
+

确认您的邮箱地址变更

+

您正在更新与您的 Dify 账户关联的邮箱地址。 + + 为了确认此操作,请使用以下验证码。 + 此验证码仅在接下来的5分钟内有效:

+
+ {{code}} +
+

如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。

+
+ + + + diff --git a/api/templates/change_mail_confirm_old_template_en-US.html b/api/templates/change_mail_confirm_old_template_en-US.html new file mode 100644 index 0000000000..e5a1cac373 --- /dev/null +++ b/api/templates/change_mail_confirm_old_template_en-US.html @@ -0,0 +1,89 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Verify Your Request to Change Email

+

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:

+
+ {{code}} +
+

If you didn’t make this request, please ignore this email or contact support immediately.

+
+ + + + diff --git a/api/templates/change_mail_confirm_old_template_zh-CN.html b/api/templates/change_mail_confirm_old_template_zh-CN.html new file mode 100644 index 0000000000..ce1deccc9e --- /dev/null +++ b/api/templates/change_mail_confirm_old_template_zh-CN.html @@ -0,0 +1,89 @@ + + + + + + + + +
+
+ + Dify Logo +
+

验证您的邮箱变更请求

+

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。 + + 我们收到了一个变更您 Dify 账户关联邮箱地址的请求。 + 此验证码仅在接下来的5分钟内有效:

+
+ {{code}} +
+

如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。

+
+ + + + diff --git a/api/templates/transfer_workspace_new_owner_notify_template_en-US.html b/api/templates/transfer_workspace_new_owner_notify_template_en-US.html new file mode 100644 index 0000000000..469e047983 --- /dev/null +++ b/api/templates/transfer_workspace_new_owner_notify_template_en-US.html @@ -0,0 +1,87 @@ + + + + + + + + +
+
+ + Dify Logo +
+

You are now the owner of {{WorkspaceName}}

+

You have been assigned as the new owner of the workspace "{{WorkspaceName}}". + + As the new owner, you now have full administrative privileges for this workspace. + + If you have any questions, please contact support@dify.ai.

+ +
+ + + + diff --git a/api/templates/transfer_workspace_new_owner_notify_template_zh-CN.html b/api/templates/transfer_workspace_new_owner_notify_template_zh-CN.html new file mode 100644 index 0000000000..51f92bbbb9 --- /dev/null +++ b/api/templates/transfer_workspace_new_owner_notify_template_zh-CN.html @@ -0,0 +1,87 @@ + + + + + + + + +
+
+ + Dify Logo +
+

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

+

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

+ +
+ + + + diff --git a/api/templates/transfer_workspace_old_owner_notify_template_en-US.html b/api/templates/transfer_workspace_old_owner_notify_template_en-US.html new file mode 100644 index 0000000000..2e0d23d8ab --- /dev/null +++ b/api/templates/transfer_workspace_old_owner_notify_template_en-US.html @@ -0,0 +1,89 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Workspace ownership has been transferred

+

You have successfully transferred ownership of the workspace "{{WorkspaceName}}" to + {{NewOwnerEmail}}. + + You no longer have owner privileges for this workspace. Your access level has been changed to Admin. + + If you did not initiate this transfer or have concerns about this change, please contact support@dify.ai + immediately.

+ +
+ + + + diff --git a/api/templates/transfer_workspace_old_owner_notify_template_zh-CN.html b/api/templates/transfer_workspace_old_owner_notify_template_zh-CN.html new file mode 100644 index 0000000000..d69f763fb0 --- /dev/null +++ b/api/templates/transfer_workspace_old_owner_notify_template_zh-CN.html @@ -0,0 +1,87 @@ + + + + + + + + +
+
+ + Dify Logo +
+

工作区所有权已转移

+

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

+ +
+ + + + diff --git a/api/templates/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/transfer_workspace_owner_confirm_template_en-US.html new file mode 100644 index 0000000000..4ff9f60438 --- /dev/null +++ b/api/templates/transfer_workspace_owner_confirm_template_en-US.html @@ -0,0 +1,92 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Verify Your Request to Transfer Workspace Ownership

+

We received a request to transfer ownership of your workspace “{{WorkspaceName}}”. + + To confirm this action, please use the verification code below. + This code will only be valid for the next 5 minutes:

+
+ {{code}} +
+

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

+
+ + + + diff --git a/api/templates/transfer_workspace_owner_confirm_template_zh-CN.html b/api/templates/transfer_workspace_owner_confirm_template_zh-CN.html new file mode 100644 index 0000000000..d9e0db70fa --- /dev/null +++ b/api/templates/transfer_workspace_owner_confirm_template_zh-CN.html @@ -0,0 +1,91 @@ + + + + + + + + +
+
+ + Dify Logo +
+

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

+

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

+
+ {{code}} +
+

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

+
+ + + + diff --git a/api/templates/without-brand/change_mail_confirm_new_template_en-US.html b/api/templates/without-brand/change_mail_confirm_new_template_en-US.html new file mode 100644 index 0000000000..ccac8509bf --- /dev/null +++ b/api/templates/without-brand/change_mail_confirm_new_template_en-US.html @@ -0,0 +1,85 @@ + + + + + + + + +
+

Confirm Your New Email Address

+

You’re 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:

+
+ {{code}} +
+

If you didn’t make this request, please ignore this email or contact support immediately.

+
+ + + + diff --git a/api/templates/without-brand/change_mail_confirm_new_template_zh-CN.html b/api/templates/without-brand/change_mail_confirm_new_template_zh-CN.html new file mode 100644 index 0000000000..cd0418c236 --- /dev/null +++ b/api/templates/without-brand/change_mail_confirm_new_template_zh-CN.html @@ -0,0 +1,85 @@ + + + + + + + + +
+

确认您的邮箱地址变更

+

您正在更新与您的 Dify 账户关联的邮箱地址。 + + 为了确认此操作,请使用以下验证码。 + 此验证码仅在接下来的5分钟内有效:

+
+ {{code}} +
+

如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。

+
+ + + + diff --git a/api/templates/without-brand/change_mail_confirm_old_template_en-US.html b/api/templates/without-brand/change_mail_confirm_old_template_en-US.html new file mode 100644 index 0000000000..d267f07da9 --- /dev/null +++ b/api/templates/without-brand/change_mail_confirm_old_template_en-US.html @@ -0,0 +1,85 @@ + + + + + + + + +
+

Verify Your Request to Change Email

+

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:

+
+ {{code}} +
+

If you didn’t make this request, please ignore this email or contact support immediately.

+
+ + + + diff --git a/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html b/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html new file mode 100644 index 0000000000..e9cdd315de --- /dev/null +++ b/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html @@ -0,0 +1,85 @@ + + + + + + + + +
+

验证您的邮箱变更请求

+

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。 + + 为了确认此操作,请使用以下验证码。 + 此验证码仅在接下来的5分钟内有效:

+
+ {{code}} +
+

如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。

+
+ + + + diff --git a/api/templates/without-brand/transfer_workspace_new_owner_notify_template_en-US.html b/api/templates/without-brand/transfer_workspace_new_owner_notify_template_en-US.html new file mode 100644 index 0000000000..6c5a991ea1 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_new_owner_notify_template_en-US.html @@ -0,0 +1,82 @@ + + + + + + + + +
+

You are now the owner of {{WorkspaceName}}

+

You have been assigned as the new owner of the workspace "{{WorkspaceName}}". + + As the new owner, you now have full administrative privileges for this workspace. + + If you have any questions, please contact support@dify.ai.

+
+ + + + diff --git a/api/templates/without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html b/api/templates/without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html new file mode 100644 index 0000000000..77be3dc383 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html @@ -0,0 +1,82 @@ + + + + + + + + +
+

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

+

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

+
+ + + + diff --git a/api/templates/without-brand/transfer_workspace_old_owner_notify_template_en-US.html b/api/templates/without-brand/transfer_workspace_old_owner_notify_template_en-US.html new file mode 100644 index 0000000000..69ff2f451e --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_old_owner_notify_template_en-US.html @@ -0,0 +1,84 @@ + + + + + + + + +
+

Workspace ownership has been transferred

+

You have successfully transferred ownership of the workspace "{{WorkspaceName}}" to + {{NewOwnerEmail}}. + + You no longer have owner privileges for this workspace. Your access level has been changed to Admin. + + If you did not initiate this transfer or have concerns about this change, please contact support@dify.ai + immediately.

+
+ + + + diff --git a/api/templates/without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html b/api/templates/without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html new file mode 100644 index 0000000000..541763ff94 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html @@ -0,0 +1,82 @@ + + + + + + + + +
+

工作区所有权已转移

+

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

+
+ + + + diff --git a/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html new file mode 100644 index 0000000000..abc5173806 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html @@ -0,0 +1,89 @@ + + + + + + + + +
+

Verify Your Request to Transfer Workspace Ownership

+

We received a request to transfer ownership of your workspace “{{WorkspaceName}}”. + + To confirm this action, please use the verification code below. + This code will only be valid for the next 5 minutes:

+
+ {{code}} +
+

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

+
+ + + + diff --git a/api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html b/api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html new file mode 100644 index 0000000000..1812928db3 --- /dev/null +++ b/api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html @@ -0,0 +1,88 @@ + + + + + + + + +
+

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

+

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

+
+ {{code}} +
+

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

+
+ + + + diff --git a/api/tests/integration_tests/.env.example b/api/tests/integration_tests/.env.example index 4046096c27..2e98dec964 100644 --- a/api/tests/integration_tests/.env.example +++ b/api/tests/integration_tests/.env.example @@ -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 diff --git a/docker/.env.example b/docker/.env.example index dabd66f285..38b1a39815 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 61362ed9fd..c54cd6621a 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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} diff --git a/web/app/account/account-page/email-change-modal.tsx b/web/app/account/account-page/email-change-modal.tsx new file mode 100644 index 0000000000..8d26ca66f3 --- /dev/null +++ b/web/app/account/account-page/email-change-modal.tsx @@ -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.start) + const [code, setCode] = useState('') + const [mail, setMail] = useState('') + const [time, setTime] = useState(0) + const [stepToken, setStepToken] = useState('') + const [newEmailExited, setNewEmailExited] = useState(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 ( + +
+ +
+ {step === STEP.start && ( + <> +
{t('common.account.changeEmail.title')}
+
+
{t('common.account.changeEmail.authTip')}
+
+ }} + values={{ email }} + /> +
+
+
+
+ + +
+ + )} + {step === STEP.verifyOrigin && ( + <> +
{t('common.account.changeEmail.verifyEmail')}
+
+
+ }} + values={{ email }} + /> +
+
+
+
{t('common.account.changeEmail.codeLabel')}
+ setCode(e.target.value)} + maxLength={6} + /> +
+
+ + +
+
+ {t('common.account.changeEmail.resendTip')} + {time > 0 && ( + {t('common.account.changeEmail.resendCount', { count: time })} + )} + {!time && ( + {t('common.account.changeEmail.resend')} + )} +
+ + )} + {step === STEP.newEmail && ( + <> +
{t('common.account.changeEmail.newEmail')}
+
+
{t('common.account.changeEmail.content3')}
+
+
+
{t('common.account.changeEmail.emailLabel')}
+ handleNewEmailValueChange(e.target.value)} + destructive={newEmailExited} + /> + {newEmailExited && ( +
{t('common.account.changeEmail.existingEmail')}
+ )} +
+
+ + +
+ + )} + {step === STEP.verifyNew && ( + <> +
{t('common.account.changeEmail.verifyNew')}
+
+
+ }} + values={{ email: mail }} + /> +
+
+
+
{t('common.account.changeEmail.codeLabel')}
+ setCode(e.target.value)} + maxLength={6} + /> +
+
+ + +
+
+ {t('common.account.changeEmail.resendTip')} + {time > 0 && ( + {t('common.account.changeEmail.resendCount', { count: time })} + )} + {!time && ( + {t('common.account.changeEmail.resend')} + )} +
+ + )} +
+ ) +} + +export default EmailChangeModal diff --git a/web/app/account/account-page/index.module.css b/web/app/account/account-page/index.module.css deleted file mode 100644 index 949d1257e9..0000000000 --- a/web/app/account/account-page/index.module.css +++ /dev/null @@ -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; -} - diff --git a/web/app/account/account-page/index.tsx b/web/app/account/account-page/index.tsx index ffaecb79ef..55fa2983dd 100644 --- a/web/app/account/account-page/index.tsx +++ b/web/app/account/account-page/index.tsx @@ -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 (
- +
{item.name}
@@ -169,6 +177,11 @@ export default function AccountPage() {
{userProfile.email}
+ {systemFeatures.enable_change_email && ( +
setShowUpdateEmail(true)}> + {t('common.operation.change')} +
+ )} { @@ -189,7 +202,7 @@ export default function AccountPage() { {!!apps.length && ( ({ 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() { setEditNameModalVisible(false)} - className={s.modal} + className='!w-[420px] !p-6' >
{t('common.account.editName')}
{t('common.account.name')}
@@ -230,7 +243,7 @@ export default function AccountPage() { setEditPasswordModalVisible(false) resetPasswordForm() }} - className={s.modal} + className='!w-[420px] !p-6' >
{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}
{userProfile.is_password_set && ( @@ -315,6 +328,13 @@ export default function AccountPage() { /> ) } + {showUpdateEmail && ( + setShowUpdateEmail(false)} + email={userProfile.email} + /> + )} ) } diff --git a/web/app/components/billing/type.ts b/web/app/components/billing/type.ts index 506ef46799..9090cec78a 100644 --- a/web/app/components/billing/type.ts +++ b/web/app/components/billing/type.ts @@ -99,7 +99,8 @@ export type CurrentPlanInfoBackend = { workspace_members: { size: number limit: number - } + }, + is_allow_transfer_workspace: boolean } export type SubscriptionItem = { diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index df7aae6b62..ad04371d4a 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -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([]) 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 = () => {
{dayjs(Number((account.last_active_at || account.created_at)) * 1000).locale(locale === 'zh-Hans' ? 'zh-cn' : 'en').fromNow()}
- { - isCurrentWorkspaceOwner && account.role !== 'owner' - ? - :
{RoleMap[account.role] || RoleMap.normal}
- } + {isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && ( + setShowTransferOwnershipModal(true)}> + )} + {isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && ( +
{RoleMap[account.role] || RoleMap.normal}
+ )} + {isCurrentWorkspaceOwner && account.role !== 'owner' && ( + + )} + {!isCurrentWorkspaceOwner && ( +
{RoleMap[account.role] || RoleMap.normal}
+ )}
)) @@ -173,6 +183,12 @@ const MembersPage = () => { /> ) } + {showTransferOwnershipModal && ( + setShowTransferOwnershipModal(false)} + /> + )} ) } diff --git a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx new file mode 100644 index 0000000000..bbf1a0351a --- /dev/null +++ b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx @@ -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 ( + + { + ({ open }) => ( + <> + + {t('common.members.owner')} + + + + +
+ +
+
{t('common.members.transferOwnership')}
+
+
+
+
+
+ + ) + } +
+ ) +} + +export default TransferOwnership diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx new file mode 100644 index 0000000000..1811d799a3 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -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.start) + const [code, setCode] = useState('') + const [time, setTime] = useState(0) + const [stepToken, setStepToken] = useState('') + const [newOwner, setNewOwner] = useState('') + const [isTransfer, setIsTransfer] = useState(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 ( + +
+ +
+ {step === STEP.start && ( + <> +
{t('common.members.transferModal.title')}
+
+
{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '’') })}
+
{t('common.members.transferModal.warningTip')}
+
+ }} + values={{ email: userProfile.email }} + /> +
+
+
+
+ + +
+ + )} + {step === STEP.verify && ( + <> +
{t('common.members.transferModal.verifyEmail')}
+
+
+ }} + values={{ email: userProfile.email }} + /> +
+
{t('common.members.transferModal.verifyContent2')}
+
+
+
{t('common.members.transferModal.codeLabel')}
+ setCode(e.target.value)} + maxLength={6} + /> +
+
+ + +
+
+ {t('common.members.transferModal.resendTip')} + {time > 0 && ( + {t('common.members.transferModal.resendCount', { count: time })} + )} + {!time && ( + {t('common.members.transferModal.resend')} + )} +
+ + )} + {step === STEP.transfer && ( + <> +
{t('common.members.transferModal.title')}
+
+
{t('common.members.transferModal.warning', { workspace: currentWorkspace.name.replace(/'/g, '’') })}
+
{t('common.members.transferModal.warningTip')}
+
+
+
{t('common.members.transferModal.transferLabel')}
+ +
+
+ + +
+ + )} +
+ ) +} + +export default TransferOwnershipModal diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx new file mode 100644 index 0000000000..5c3e69b790 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx @@ -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 = ({ + 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 ( + + setOpen(v => !v)} + > +
+ {!currentValue && ( +
{t('common.members.transferModal.transferPlaceholder')}
+ )} + {currentValue && ( + <> + +
{currentValue.name}
+
{currentValue.email}
+ + )} + +
+
+ +
+
+ setSearchValue(e.target.value)} + /> +
+
+ {filteredList.map(account => ( +
{ + onSelect(account.id) + setOpen(false) + }} + > + +
{account.name}
+
{account.email}
+
+ ))} +
+
+
+
+ ) +} +export default MemberSelector diff --git a/web/context/provider-context.tsx b/web/context/provider-context.tsx index aaec900fd8..70917f2cf6 100644 --- a/web/context/provider-context.tsx +++ b/web/context/provider-context.tsx @@ -56,6 +56,7 @@ type ProviderContextState = { } }, refreshLicenseLimit: () => void + isAllowTransferWorkspace: boolean } const ProviderContext = createContext({ modelProviders: [], @@ -97,6 +98,7 @@ const ProviderContext = createContext({ }, }, 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} diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 0823c6129c..0ea80b8368 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -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}} for re-authentication.', + content2: 'Your current email is {{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}}.', + 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}} for re-authentication.', + verifyEmail: 'Verify your email', + verifyContent: 'Your current email is {{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', diff --git a/web/i18n/ja-JP/common.ts b/web/i18n/ja-JP/common.ts index 0c33c83847..ad4e3008bf 100644 --- a/web/i18n/ja-JP/common.ts +++ b/web/i18n/ja-JP/common.ts @@ -234,6 +234,28 @@ const translation = { editWorkspaceInfo: 'ワークスペース情報を編集', workspaceName: 'ワークスペース名', workspaceIcon: 'ワークスペースアイコン', + changeEmail: { + title: 'メールアドレスを変更', + verifyEmail: '現在のメールアドレスを確認してください', + newEmail: '新しいメールアドレスを設定する', + verifyNew: '新しいメールアドレスを確認する', + authTip: 'メールアドレスが変更されると、旧メールアドレスにリンクされている Google または GitHub アカウントは、このアカウントにログインできなくなります。', + content1: '続行すると、再認証のために確認コードが {{email}> に送信されます。', + content2: '現在のメールアドレスは {{email}} です。確認コードはこのメールアドレスに送信されました。', + content3: '新しいメールアドレスを入力すると、確認コードが送信されます。', + content4: '一時確認コードを {{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}} に認証コードを送信します。', + verifyEmail: '現在のメールアドレスを確認', + verifyContent: '現在のメールアドレスは {{email}}。', + verifyContent2: 'このメールアドレスに一時的な認証コードを送信し、再認証を行います。', + codeLabel: '認証コード', + codePlaceholder: '6 桁のコードを入力してください', + resendTip: '認証コードを受け取れない場合は、', + resendCount: '{{count}} 秒後に再送信', + resend: '認証コードを再送信', + transferLabel: 'ワークスペースの所有権を転移する相手は', + transferPlaceholder: 'メールアドレスを入力してください', + sendVerifyCode: '認証コードを送信', + continue: '続行する', + transfer: 'ワークスペースの所有権を移する', + }, }, integrations: { connected: '接続済み', diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index 39964bb6b0..a3a92b92d3 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -233,6 +233,28 @@ const translation = { editWorkspaceInfo: '编辑工作空间信息', workspaceName: '工作空间名称', workspaceIcon: '工作空间图标', + changeEmail: { + title: '更改邮箱', + verifyEmail: '验证当前邮箱', + newEmail: '设置新邮箱', + verifyNew: '验证新邮箱', + authTip: '一旦您的电子邮件地址更改,链接到您旧电子邮件地址的 Google 或 GitHub 帐户将无法再登录该帐户。', + content1: '如果您继续,我们将向 {{email}} 发送验证码以进行重新验证。', + content2: '你的当前邮箱是 {{email}} 。验证码已发送至该邮箱。', + content3: '输入新的电子邮件,我们将向您发送验证码。', + content4: '我们已将验证码发送至 {{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}} 发送验证码以进行重新验证。', + verifyEmail: '验证你的邮箱', + verifyContent: '您当前的电子邮件地址是 {{email}}。', + verifyContent2: '我们将向此电子邮件发送临时验证码,以便重新进行身份验证。', + codeLabel: '验证码', + codePlaceholder: '输入 6 位数字验证码', + resendTip: '没有收到验证码?', + resendCount: '请在 {{count}} 秒后重新发送', + resend: '重新发送', + transferLabel: '新所有者', + transferPlaceholder: '选择一个成员', + sendVerifyCode: '发送验证码', + continue: '继续', + transfer: '转移工作空间所有权', + }, }, integrations: { connected: '登录方式', diff --git a/web/service/common.ts b/web/service/common.ts index e071d556d1..c99825c05a 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -131,6 +131,15 @@ export const deleteMemberOrCancelInvitation: Fetcher(url) } +export const sendOwnerEmail = (body: { language?: string }) => + post('/workspaces/current/members/send-owner-transfer-confirm-email', { body }) + +export const verifyOwnerEmail = (body: { code: string; token: string }) => + post('/workspaces/current/members/owner-transfer-check', { body }) + +export const ownershipTransfer = (memberID: string, body: { token: string }) => + post(`/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('/account/change-email', { body }) + +export const verifyEmail = (body: { email: string; code: string; token: string }) => + post('/account/change-email/validity', { body }) + +export const resetEmail = (body: { new_email: string; token: string }) => + post('/account/change-email/reset', { body }) + +export const checkEmailExisted = (body: { email: string }) => + post('/account/change-email/check-email-unique', { body }) diff --git a/web/types/feature.ts b/web/types/feature.ts index 5787c2661f..088317d7fd 100644 --- a/web/types/feature.ts +++ b/web/types/feature.ts @@ -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,