mirror of
https://github.com/langgenius/dify.git
synced 2026-01-07 06:48:28 +00:00
Compare commits
28 Commits
feat/itera
...
temp-feat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb1f8b7ca | ||
|
|
ac7d258914 | ||
|
|
28fca88b35 | ||
|
|
21756f1110 | ||
|
|
221187c1bc | ||
|
|
4176bca008 | ||
|
|
9c4f24c6d7 | ||
|
|
3ab3bee9c6 | ||
|
|
a93c76199e | ||
|
|
77c91cdd63 | ||
|
|
59a3686379 | ||
|
|
077f44ff58 | ||
|
|
58f67acf07 | ||
|
|
856d07ca2f | ||
|
|
2a0a315187 | ||
|
|
3e18dd41e7 | ||
|
|
128f8de382 | ||
|
|
8ead8961ce | ||
|
|
8a888e082d | ||
|
|
513af98729 | ||
|
|
35d2a935a3 | ||
|
|
207d0f78f5 | ||
|
|
56e8b9711d | ||
|
|
e4b6c33bdd | ||
|
|
c0e73c1659 | ||
|
|
434c809866 | ||
|
|
6ab09397ef | ||
|
|
3bead19f19 |
@@ -477,6 +477,8 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
|
||||
|
||||
# Reset password token expiry minutes
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
|
||||
|
||||
CREATE_TIDB_SERVICE_JOB_ENABLED=false
|
||||
|
||||
|
||||
@@ -31,6 +31,15 @@ class SecurityConfig(BaseSettings):
|
||||
description="Duration in minutes for which a password reset token remains valid",
|
||||
default=5,
|
||||
)
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
|
||||
description="Duration in minutes for which a change email token remains valid",
|
||||
default=5,
|
||||
)
|
||||
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
|
||||
description="Duration in minutes for which a owner transfer token remains valid",
|
||||
default=5,
|
||||
)
|
||||
|
||||
LOGIN_DISABLED: bool = Field(
|
||||
description="Whether to disable login checks",
|
||||
@@ -580,6 +589,16 @@ class AuthConfig(BaseSettings):
|
||||
default=86400,
|
||||
)
|
||||
|
||||
CHANGE_EMAIL_LOCKOUT_DURATION: PositiveInt = Field(
|
||||
description="Time (in seconds) a user must wait before retrying change email after exceeding the rate limit.",
|
||||
default=86400,
|
||||
)
|
||||
|
||||
OWNER_TRANSFER_LOCKOUT_DURATION: PositiveInt = Field(
|
||||
description="Time (in seconds) a user must wait before retrying owner transfer after exceeding the rate limit.",
|
||||
default=86400,
|
||||
)
|
||||
|
||||
|
||||
class ModerationConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@@ -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"
|
||||
@@ -546,6 +702,56 @@ class AccountService:
|
||||
key = f"forgot_password_error_rate_limit:{email}"
|
||||
redis_client.delete(key)
|
||||
|
||||
@staticmethod
|
||||
def add_change_email_error_rate_limit(email: str) -> None:
|
||||
key = f"change_email_error_rate_limit:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
count = 0
|
||||
count = int(count) + 1
|
||||
redis_client.setex(key, dify_config.CHANGE_EMAIL_LOCKOUT_DURATION, count)
|
||||
|
||||
@staticmethod
|
||||
def is_change_email_error_rate_limit(email: str) -> bool:
|
||||
key = f"change_email_error_rate_limit:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
return False
|
||||
count = int(count)
|
||||
if count > AccountService.CHANGE_EMAIL_MAX_ERROR_LIMITS:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def reset_change_email_error_rate_limit(email: str):
|
||||
key = f"change_email_error_rate_limit:{email}"
|
||||
redis_client.delete(key)
|
||||
|
||||
@staticmethod
|
||||
def add_owner_transfer_error_rate_limit(email: str) -> None:
|
||||
key = f"owner_transfer_error_rate_limit:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
count = 0
|
||||
count = int(count) + 1
|
||||
redis_client.setex(key, dify_config.OWNER_TRANSFER_LOCKOUT_DURATION, count)
|
||||
|
||||
@staticmethod
|
||||
def is_owner_transfer_error_rate_limit(email: str) -> bool:
|
||||
key = f"owner_transfer_error_rate_limit:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
return False
|
||||
count = int(count)
|
||||
if count > AccountService.OWNER_TRANSFER_MAX_ERROR_LIMITS:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def reset_owner_transfer_error_rate_limit(email: str):
|
||||
key = f"owner_transfer_error_rate_limit:{email}"
|
||||
redis_client.delete(key)
|
||||
|
||||
@staticmethod
|
||||
def is_email_send_ip_limit(ip_address: str):
|
||||
minute_key = f"email_send_ip_limit_minute:{ip_address}"
|
||||
@@ -586,6 +792,10 @@ class AccountService:
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def check_email_unique(email: str) -> bool:
|
||||
return db.session.query(Account).filter_by(email=email).first() is None
|
||||
|
||||
|
||||
class TenantService:
|
||||
@staticmethod
|
||||
@@ -858,6 +1068,15 @@ class TenantService:
|
||||
|
||||
return cast(dict, tenant.custom_config_dict)
|
||||
|
||||
@staticmethod
|
||||
def is_owner(account: Account, tenant: Tenant) -> bool:
|
||||
return TenantService.get_user_role(account, tenant) == TenantAccountRole.OWNER
|
||||
|
||||
@staticmethod
|
||||
def is_member(account: Account, tenant: Tenant) -> bool:
|
||||
"""Check if the account is a member of the tenant"""
|
||||
return TenantService.get_user_role(account, tenant) is not None
|
||||
|
||||
|
||||
class RegisterService:
|
||||
@classmethod
|
||||
|
||||
@@ -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"]
|
||||
|
||||
78
api/tasks/mail_change_mail_task.py
Normal file
78
api/tasks/mail_change_mail_task.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import click
|
||||
from celery import shared_task # type: ignore
|
||||
from flask import render_template
|
||||
|
||||
from extensions.ext_mail import mail
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
@shared_task(queue="mail")
|
||||
def send_change_mail_task(language: str, to: str, code: str, phase: str):
|
||||
"""
|
||||
Async Send change email mail
|
||||
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
|
||||
:param to: Recipient email address
|
||||
:param code: Change email code
|
||||
:param phase: Change email phase (new_email, old_email)
|
||||
"""
|
||||
if not mail.is_inited():
|
||||
return
|
||||
|
||||
logging.info(click.style("Start change email mail to {}".format(to), fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
|
||||
email_config = {
|
||||
"zh-Hans": {
|
||||
"old_email": {
|
||||
"subject": "检测您现在的邮箱",
|
||||
"template_with_brand": "change_mail_confirm_old_template_zh-CN.html",
|
||||
"template_without_brand": "without-brand/change_mail_confirm_old_template_zh-CN.html",
|
||||
},
|
||||
"new_email": {
|
||||
"subject": "确认您的邮箱地址变更",
|
||||
"template_with_brand": "change_mail_confirm_new_template_zh-CN.html",
|
||||
"template_without_brand": "without-brand/change_mail_confirm_new_template_zh-CN.html",
|
||||
},
|
||||
},
|
||||
"en": {
|
||||
"old_email": {
|
||||
"subject": "Check your current email",
|
||||
"template_with_brand": "change_mail_confirm_old_template_en-US.html",
|
||||
"template_without_brand": "without-brand/change_mail_confirm_old_template_en-US.html",
|
||||
},
|
||||
"new_email": {
|
||||
"subject": "Confirm your new email address",
|
||||
"template_with_brand": "change_mail_confirm_new_template_en-US.html",
|
||||
"template_without_brand": "without-brand/change_mail_confirm_new_template_en-US.html",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# send change email mail using different languages
|
||||
try:
|
||||
system_features = FeatureService.get_system_features()
|
||||
lang_key = "zh-Hans" if language == "zh-Hans" else "en"
|
||||
|
||||
if phase not in ["old_email", "new_email"]:
|
||||
raise ValueError("Invalid phase")
|
||||
|
||||
config = email_config[lang_key][phase]
|
||||
subject = config["subject"]
|
||||
|
||||
if system_features.branding.enabled:
|
||||
template = config["template_without_brand"]
|
||||
else:
|
||||
template = config["template_with_brand"]
|
||||
|
||||
html_content = render_template(template, to=to, code=code)
|
||||
mail.send(to=to, subject=subject, html=html_content)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style("Send change email mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green")
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Send change email mail to {} failed".format(to))
|
||||
152
api/tasks/mail_owner_transfer_task.py
Normal file
152
api/tasks/mail_owner_transfer_task.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import click
|
||||
from celery import shared_task # type: ignore
|
||||
from flask import render_template
|
||||
|
||||
from extensions.ext_mail import mail
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
@shared_task(queue="mail")
|
||||
def send_owner_transfer_confirm_task(language: str, to: str, code: str, workspace: str):
|
||||
"""
|
||||
Async Send owner transfer confirm mail
|
||||
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
|
||||
:param to: Recipient email address
|
||||
:param workspace: Workspace name
|
||||
"""
|
||||
if not mail.is_inited():
|
||||
return
|
||||
|
||||
logging.info(click.style("Start change email mail to {}".format(to), fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
# send change email mail using different languages
|
||||
try:
|
||||
if language == "zh-Hans":
|
||||
template = "transfer_workspace_owner_confirm_template_zh-CN.html"
|
||||
system_features = FeatureService.get_system_features()
|
||||
if system_features.branding.enabled:
|
||||
template = "without-brand/transfer_workspace_owner_confirm_template_zh-CN.html"
|
||||
html_content = render_template(template, to=to, code=code, WorkspaceName=workspace)
|
||||
mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content)
|
||||
else:
|
||||
html_content = render_template(template, to=to, code=code, WorkspaceName=workspace)
|
||||
mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content)
|
||||
else:
|
||||
template = "transfer_workspace_owner_confirm_template_en-US.html"
|
||||
system_features = FeatureService.get_system_features()
|
||||
if system_features.branding.enabled:
|
||||
template = "without-brand/transfer_workspace_owner_confirm_template_en-US.html"
|
||||
html_content = render_template(template, to=to, code=code, WorkspaceName=workspace)
|
||||
mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content)
|
||||
else:
|
||||
html_content = render_template(template, to=to, code=code, WorkspaceName=workspace)
|
||||
mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style(
|
||||
"Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at),
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("owner transfer confirm email mail to {} failed".format(to))
|
||||
|
||||
|
||||
@shared_task(queue="mail")
|
||||
def send_old_owner_transfer_notify_email_task(language: str, to: str, workspace: str, new_owner_email: str):
|
||||
"""
|
||||
Async Send owner transfer confirm mail
|
||||
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
|
||||
:param to: Recipient email address
|
||||
:param workspace: Workspace name
|
||||
:param new_owner_email: New owner email
|
||||
"""
|
||||
if not mail.is_inited():
|
||||
return
|
||||
|
||||
logging.info(click.style("Start change email mail to {}".format(to), fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
# send change email mail using different languages
|
||||
try:
|
||||
if language == "zh-Hans":
|
||||
template = "transfer_workspace_old_owner_notify_template_zh-CN.html"
|
||||
system_features = FeatureService.get_system_features()
|
||||
if system_features.branding.enabled:
|
||||
template = "without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html"
|
||||
html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email)
|
||||
mail.send(to=to, subject="工作区所有权已转移", html=html_content)
|
||||
else:
|
||||
html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email)
|
||||
mail.send(to=to, subject="工作区所有权已转移", html=html_content)
|
||||
else:
|
||||
template = "transfer_workspace_old_owner_notify_template_en-US.html"
|
||||
system_features = FeatureService.get_system_features()
|
||||
if system_features.branding.enabled:
|
||||
template = "without-brand/transfer_workspace_old_owner_notify_template_en-US.html"
|
||||
html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email)
|
||||
mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content)
|
||||
else:
|
||||
html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email)
|
||||
mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style(
|
||||
"Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at),
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("owner transfer confirm email mail to {} failed".format(to))
|
||||
|
||||
|
||||
@shared_task(queue="mail")
|
||||
def send_new_owner_transfer_notify_email_task(language: str, to: str, workspace: str):
|
||||
"""
|
||||
Async Send owner transfer confirm mail
|
||||
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
|
||||
:param to: Recipient email address
|
||||
:param code: Change email code
|
||||
:param workspace: Workspace name
|
||||
"""
|
||||
if not mail.is_inited():
|
||||
return
|
||||
|
||||
logging.info(click.style("Start change email mail to {}".format(to), fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
# send change email mail using different languages
|
||||
try:
|
||||
if language == "zh-Hans":
|
||||
template = "transfer_workspace_new_owner_notify_template_zh-CN.html"
|
||||
system_features = FeatureService.get_system_features()
|
||||
if system_features.branding.enabled:
|
||||
template = "without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html"
|
||||
html_content = render_template(template, to=to, WorkspaceName=workspace)
|
||||
mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content)
|
||||
else:
|
||||
html_content = render_template(template, to=to, WorkspaceName=workspace)
|
||||
mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content)
|
||||
else:
|
||||
template = "transfer_workspace_new_owner_notify_template_en-US.html"
|
||||
system_features = FeatureService.get_system_features()
|
||||
if system_features.branding.enabled:
|
||||
template = "without-brand/transfer_workspace_new_owner_notify_template_en-US.html"
|
||||
html_content = render_template(template, to=to, WorkspaceName=workspace)
|
||||
mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content)
|
||||
else:
|
||||
html_content = render_template(template, to=to, WorkspaceName=workspace)
|
||||
mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style(
|
||||
"Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at),
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("owner transfer confirm email mail to {} failed".format(to))
|
||||
89
api/templates/change_mail_confirm_new_template_en-US.html
Normal file
89
api/templates/change_mail_confirm_new_template_en-US.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">Confirm Your New Email Address</p>
|
||||
<p class="description">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:</p>
|
||||
<div class="code-content">
|
||||
<span class="code">{{code}}</span>
|
||||
</div>
|
||||
<p class="tips">If you didn’t make this request, please ignore this email or contact support immediately.</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
89
api/templates/change_mail_confirm_new_template_zh-CN.html
Normal file
89
api/templates/change_mail_confirm_new_template_zh-CN.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">确认您的邮箱地址变更</p>
|
||||
<p class="description">您正在更新与您的 Dify 账户关联的邮箱地址。
|
||||
|
||||
为了确认此操作,请使用以下验证码。
|
||||
此验证码仅在接下来的5分钟内有效:</p>
|
||||
<div class="code-content">
|
||||
<span class="code">{{code}}</span>
|
||||
</div>
|
||||
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
89
api/templates/change_mail_confirm_old_template_en-US.html
Normal file
89
api/templates/change_mail_confirm_old_template_en-US.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">Verify Your Request to Change Email</p>
|
||||
<p class="description">We received a request to change the email address associated with your Dify account.
|
||||
|
||||
To confirm this action, please use the verification code below.
|
||||
This code will only be valid for the next 5 minutes:</p>
|
||||
<div class="code-content">
|
||||
<span class="code">{{code}}</span>
|
||||
</div>
|
||||
<p class="tips">If you didn’t make this request, please ignore this email or contact support immediately.</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
89
api/templates/change_mail_confirm_old_template_zh-CN.html
Normal file
89
api/templates/change_mail_confirm_old_template_zh-CN.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">验证您的邮箱变更请求</p>
|
||||
<p class="description"> 我们收到了一个变更您 Dify 账户关联邮箱地址的请求。
|
||||
|
||||
我们收到了一个变更您 Dify 账户关联邮箱地址的请求。
|
||||
此验证码仅在接下来的5分钟内有效:</p>
|
||||
<div class="code-content">
|
||||
<span class="code">{{code}}</span>
|
||||
</div>
|
||||
<p class="tips">如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">You are now the owner of {{WorkspaceName}}</p>
|
||||
<p class="description">You have been assigned as the new owner of the workspace "{{WorkspaceName}}".
|
||||
|
||||
As the new owner, you now have full administrative privileges for this workspace.
|
||||
|
||||
If you have any questions, please contact support@dify.ai.</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">您现在是 {{WorkspaceName}} 的所有者</p>
|
||||
<p class="description">您已被分配为工作空间“{{WorkspaceName}}”的新所有者。
|
||||
|
||||
作为新所有者,您现在对该工作空间拥有完全的管理权限。
|
||||
|
||||
如果您有任何问题,请联系support@dify.ai。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">Workspace ownership has been transferred</p>
|
||||
<p class="description">You have successfully transferred ownership of the workspace "{{WorkspaceName}}" to
|
||||
{{NewOwnerEmail}}.
|
||||
|
||||
You no longer have owner privileges for this workspace. Your access level has been changed to Admin.
|
||||
|
||||
If you did not initiate this transfer or have concerns about this change, please contact support@dify.ai
|
||||
immediately.</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">工作区所有权已转移</p>
|
||||
<p class="description">您已成功将工作空间“{{WorkspaceName}}”的所有权转移给{{NewOwnerEmail}}。
|
||||
|
||||
您不再拥有此工作空间的拥有者权限。您的访问级别已更改为管理员。
|
||||
|
||||
如果您没有发起此转移或对此变更有任何疑问,请立即联系support@dify.ai。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">Verify Your Request to Transfer Workspace Ownership</p>
|
||||
<p class="description">We received a request to transfer ownership of your workspace “{{WorkspaceName}}”.
|
||||
|
||||
To confirm this action, please use the verification code below.
|
||||
This code will only be valid for the next 5 minutes:</p>
|
||||
<div class="code-content">
|
||||
<span class="code">{{code}}</span>
|
||||
</div>
|
||||
<p class="tips">Please note: The ownership transfer will take effect immediately once confirmed and cannot be
|
||||
undone.
|
||||
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.</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Optional: Add a logo or a header image here -->
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">验证您的工作空间所有权转移请求</p>
|
||||
<p class="description">我们收到了将您的工作空间“{{WorkspaceName}}”的所有权转移的请求。
|
||||
|
||||
为了确认此操作,请使用以下验证码。
|
||||
此验证码仅在5分钟内有效:</p>
|
||||
<div class="code-content">
|
||||
<span class="code">{{code}}</span>
|
||||
</div>
|
||||
<p class="tips">请注意:所有权转移一旦确认将立即生效且无法撤销。您将成为管理员成员,新的所有者将拥有工作空间的完全控制权。
|
||||
如果您没有发起此请求,请忽略此邮件或立即联系客服。
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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">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:</p>
|
||||
<div class="code-content">
|
||||
<span class="code">{{code}}</span>
|
||||
</div>
|
||||
<p class="tips">If you didn’t 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 didn’t 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,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<p class="title">You are now the owner of {{WorkspaceName}}</p>
|
||||
<p class="description">You have been assigned as the new owner of the workspace "{{WorkspaceName}}".
|
||||
|
||||
As the new owner, you now have full administrative privileges for this workspace.
|
||||
|
||||
If you have any questions, please contact support@dify.ai.</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<p class="title">您现在是 {{WorkspaceName}} 的所有者</p>
|
||||
<p class="description">您已被分配为工作空间“{{WorkspaceName}}”的新所有者。
|
||||
|
||||
作为新所有者,您现在对该工作空间拥有完全的管理权限。
|
||||
|
||||
如果您有任何问题,请联系support@dify.ai。</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<p class="title">Workspace ownership has been transferred</p>
|
||||
<p class="description">You have successfully transferred ownership of the workspace "{{WorkspaceName}}" to
|
||||
{{NewOwnerEmail}}.
|
||||
|
||||
You no longer have owner privileges for this workspace. Your access level has been changed to Admin.
|
||||
|
||||
If you did not initiate this transfer or have concerns about this change, please contact support@dify.ai
|
||||
immediately.</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<p class="title">工作区所有权已转移</p>
|
||||
<p class="description">您已成功将工作空间“{{WorkspaceName}}”的所有权转移给{{NewOwnerEmail}}。
|
||||
|
||||
您不再拥有此工作空间的拥有者权限。您的访问级别已更改为管理员。
|
||||
|
||||
如果您没有发起此转移或对此变更有任何疑问,请立即联系support@dify.ai。</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #101828;
|
||||
background-color: #e9ebf0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
height: 360px;
|
||||
margin: 40px auto;
|
||||
padding: 36px 48px;
|
||||
background-color: #fcfcfd;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 28.8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #f2f4f7;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
line-height: 16px;
|
||||
color: #676f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<p class="title">Verify Your Request to Transfer Workspace Ownership</p>
|
||||
<p class="description">We received a request to transfer ownership of your workspace “{{WorkspaceName}}”.
|
||||
|
||||
To confirm this action, please use the verification code below.
|
||||
This code will only be valid for the next 5 minutes:</p>
|
||||
<div class="code-content">
|
||||
<span class="code">{{code}}</span>
|
||||
</div>
|
||||
<p class="tips">Please note: The ownership transfer will take effect immediately once confirmed and cannot be
|
||||
undone.
|
||||
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.</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">
|
||||
<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
|
||||
|
||||
|
||||
@@ -763,6 +763,8 @@ INVITE_EXPIRY_HOURS=72
|
||||
|
||||
# Reset password token valid time (minutes),
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
|
||||
|
||||
# The sandbox service endpoint.
|
||||
CODE_EXECUTION_ENDPOINT=http://sandbox:8194
|
||||
|
||||
@@ -332,6 +332,8 @@ x-shared-env: &shared-api-worker-env
|
||||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
|
||||
INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5}
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5}
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5}
|
||||
CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194}
|
||||
CODE_EXECUTION_API_KEY: ${CODE_EXECUTION_API_KEY:-dify-sandbox}
|
||||
CODE_MAX_NUMBER: ${CODE_MAX_NUMBER:-9223372036854775807}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import Main from '@/app/components/explore/installed-app'
|
||||
|
||||
export type IInstalledAppProps = {
|
||||
params: Promise<{
|
||||
params: {
|
||||
appId: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
const InstalledApp: FC<IInstalledAppProps> = async ({ params }) => {
|
||||
// Using Next.js page convention for async server components
|
||||
async function InstalledApp({ params }: IInstalledAppProps) {
|
||||
const appId = (await params).appId
|
||||
return (
|
||||
<Main id={(await params).appId} />
|
||||
<Main id={appId} />
|
||||
)
|
||||
}
|
||||
export default React.memo(InstalledApp)
|
||||
|
||||
export default InstalledApp
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import ChatWithHistoryWrap from '@/app/components/base/chat/chat-with-history'
|
||||
import AuthenticatedLayout from '../../components/authenticated-layout'
|
||||
|
||||
const Chat = () => {
|
||||
return (
|
||||
<ChatWithHistoryWrap />
|
||||
<AuthenticatedLayout>
|
||||
<ChatWithHistoryWrap />
|
||||
</AuthenticatedLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import EmbeddedChatbot from '@/app/components/base/chat/embedded-chatbot'
|
||||
import AuthenticatedLayout from '../../components/authenticated-layout'
|
||||
|
||||
const Chatbot = () => {
|
||||
return (
|
||||
<EmbeddedChatbot />
|
||||
<AuthenticatedLayout>
|
||||
<EmbeddedChatbot />
|
||||
</AuthenticatedLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from 'react'
|
||||
import Main from '@/app/components/share/text-generation'
|
||||
import AuthenticatedLayout from '../../components/authenticated-layout'
|
||||
|
||||
const Completion = () => {
|
||||
return (
|
||||
<Main />
|
||||
<AuthenticatedLayout>
|
||||
<Main />
|
||||
</AuthenticatedLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
84
web/app/(shareLayout)/components/authenticated-layout.tsx
Normal file
84
web/app/(shareLayout)/components/authenticated-layout.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { removeAccessToken } from '@/app/components/share/utils'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const { t } = useTranslation()
|
||||
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
|
||||
const updateAppParams = useWebAppStore(s => s.updateAppParams)
|
||||
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
|
||||
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
|
||||
const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetWebAppParams()
|
||||
const { isFetching: isFetchingAppInfo, data: appInfo, error: appInfoError } = useGetWebAppInfo()
|
||||
const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetWebAppMeta()
|
||||
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (appInfo)
|
||||
updateAppInfo(appInfo)
|
||||
if (appParams)
|
||||
updateAppParams(appParams)
|
||||
if (appMeta)
|
||||
updateWebAppMeta(appMeta)
|
||||
updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result))
|
||||
}, [appInfo, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp])
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const getSigninUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('message')
|
||||
params.set('redirect_url', pathname)
|
||||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams, pathname])
|
||||
|
||||
const backToHome = useCallback(() => {
|
||||
removeAccessToken()
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
|
||||
if (appInfoError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={appInfoError.message} />
|
||||
</div>
|
||||
}
|
||||
if (appParamsError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={appParamsError.message} />
|
||||
</div>
|
||||
}
|
||||
if (appMetaError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={appMetaError.message} />
|
||||
</div>
|
||||
}
|
||||
if (useCanAccessAppError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={useCanAccessAppError.message} />
|
||||
</div>
|
||||
}
|
||||
if (userCanAccessApp && !userCanAccessApp.result) {
|
||||
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
|
||||
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
|
||||
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>
|
||||
</div>
|
||||
}
|
||||
if (isFetchingAppInfo || isFetchingAppParams || isFetchingAppMeta) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default React.memo(AuthenticatedLayout)
|
||||
80
web/app/(shareLayout)/components/splash.tsx
Normal file
80
web/app/(shareLayout)/components/splash.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import { checkOrSetAccessToken, removeAccessToken, setAccessToken } from '@/app/components/share/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
|
||||
const Splash: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
const shareCode = useWebAppStore(s => s.shareCode)
|
||||
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const redirectUrl = searchParams.get('redirect_url')
|
||||
const tokenFromUrl = searchParams.get('web_sso_token')
|
||||
const message = searchParams.get('message')
|
||||
const code = searchParams.get('code')
|
||||
const getSigninUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('message')
|
||||
params.delete('code')
|
||||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams])
|
||||
|
||||
const backToHome = useCallback(() => {
|
||||
removeAccessToken()
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (message)
|
||||
return
|
||||
if (shareCode && tokenFromUrl && redirectUrl) {
|
||||
localStorage.setItem('webapp_access_token', tokenFromUrl)
|
||||
const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: tokenFromUrl })
|
||||
await setAccessToken(shareCode, tokenResp.access_token)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
return
|
||||
}
|
||||
if (shareCode && redirectUrl && localStorage.getItem('webapp_access_token')) {
|
||||
const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: localStorage.getItem('webapp_access_token') })
|
||||
await setAccessToken(shareCode, tokenResp.access_token)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
return
|
||||
}
|
||||
if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) {
|
||||
await checkOrSetAccessToken(shareCode)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
}
|
||||
})()
|
||||
}, [shareCode, redirectUrl, router, tokenFromUrl, message, webAppAccessMode])
|
||||
|
||||
if (message) {
|
||||
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
|
||||
<AppUnavailable className='h-auto w-auto' code={code || t('share.common.appUnavailable')} unknownReason={message} />
|
||||
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')}</span>
|
||||
</div>
|
||||
}
|
||||
if (tokenFromUrl) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default Splash
|
||||
@@ -1,54 +1,15 @@
|
||||
'use client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import Loading from '../components/base/loading'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { getAppAccessModeByAppCode } from '@/service/share'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import WebAppStoreProvider from '@/context/web-app-context'
|
||||
import Splash from './components/splash'
|
||||
|
||||
const Layout: FC<{
|
||||
children: React.ReactNode
|
||||
}> = ({ children }) => {
|
||||
const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending)
|
||||
const setWebAppAccessMode = useGlobalPublicStore(s => s.setWebAppAccessMode)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectUrl = searchParams.get('redirect_url')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!isGlobalPending && !systemFeatures.webapp_auth.enabled) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let appCode: string | null = null
|
||||
if (redirectUrl) {
|
||||
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
|
||||
appCode = url.pathname.split('/').pop() || null
|
||||
}
|
||||
else {
|
||||
appCode = pathname.split('/').pop() || null
|
||||
}
|
||||
|
||||
if (!appCode)
|
||||
return
|
||||
setIsLoading(true)
|
||||
const ret = await getAppAccessModeByAppCode(appCode)
|
||||
setWebAppAccessMode(ret?.accessMode || AccessMode.PUBLIC)
|
||||
setIsLoading(false)
|
||||
})()
|
||||
}, [pathname, redirectUrl, setWebAppAccessMode, isGlobalPending, systemFeatures.webapp_auth.enabled])
|
||||
if (isLoading || isGlobalPending) {
|
||||
return <div className='flex h-full w-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
const Layout: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<div className="h-full min-w-[300px] pb-[env(safe-area-inset-bottom)]">
|
||||
{children}
|
||||
<WebAppStoreProvider>
|
||||
<Splash>
|
||||
{children}
|
||||
</Splash>
|
||||
</WebAppStoreProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
useDocumentTitle('')
|
||||
export default function SignInLayout({ children }: PropsWithChildren) {
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
useDocumentTitle(t('login.webapp.login'))
|
||||
return <>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
'use client'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { removeAccessToken, setAccessToken } from '@/app/components/share/utils'
|
||||
import { removeAccessToken } from '@/app/components/share/utils'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import NormalForm from './normalForm'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import ExternalMemberSsoAuth from './components/external-member-sso-auth'
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
const WebSSOForm: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode)
|
||||
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const redirectUrl = searchParams.get('redirect_url')
|
||||
const tokenFromUrl = searchParams.get('web_sso_token')
|
||||
const message = searchParams.get('message')
|
||||
const code = searchParams.get('code')
|
||||
|
||||
const getSigninUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('message')
|
||||
params.delete('code')
|
||||
const params = new URLSearchParams()
|
||||
params.append('redirect_url', redirectUrl || '')
|
||||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams])
|
||||
}, [redirectUrl])
|
||||
|
||||
const backToHome = useCallback(() => {
|
||||
removeAccessToken()
|
||||
@@ -38,73 +32,12 @@ const WebSSOForm: FC = () => {
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
|
||||
const showErrorToast = (msg: string) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
const getAppCodeFromRedirectUrl = useCallback(() => {
|
||||
if (!redirectUrl)
|
||||
return null
|
||||
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
|
||||
const appCode = url.pathname.split('/').pop()
|
||||
if (!appCode)
|
||||
return null
|
||||
|
||||
return appCode
|
||||
}, [redirectUrl])
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (message)
|
||||
return
|
||||
|
||||
const appCode = getAppCodeFromRedirectUrl()
|
||||
if (appCode && tokenFromUrl && redirectUrl) {
|
||||
localStorage.setItem('webapp_access_token', tokenFromUrl)
|
||||
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: tokenFromUrl })
|
||||
await setAccessToken(appCode, tokenResp.access_token)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
return
|
||||
}
|
||||
if (appCode && redirectUrl && localStorage.getItem('webapp_access_token')) {
|
||||
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: localStorage.getItem('webapp_access_token') })
|
||||
await setAccessToken(appCode, tokenResp.access_token)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
}
|
||||
})()
|
||||
}, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl, message])
|
||||
|
||||
useEffect(() => {
|
||||
if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC && redirectUrl)
|
||||
router.replace(decodeURIComponent(redirectUrl))
|
||||
}, [webAppAccessMode, router, redirectUrl])
|
||||
|
||||
if (tokenFromUrl) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
|
||||
if (message) {
|
||||
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
|
||||
<AppUnavailable className='h-auto w-auto' code={code || t('share.common.appUnavailable')} unknownReason={message} />
|
||||
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')}</span>
|
||||
</div>
|
||||
}
|
||||
if (!redirectUrl) {
|
||||
showErrorToast('redirect url is invalid.')
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable code={t('share.common.appUnavailable')} unknownReason='redirect url is invalid.' />
|
||||
</div>
|
||||
}
|
||||
if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
|
||||
if (!systemFeatures.webapp_auth.enabled) {
|
||||
return <div className="flex h-full items-center justify-center">
|
||||
<p className='system-xs-regular text-text-tertiary'>{t('login.webapp.disabled')}</p>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
import Main from '@/app/components/share/text-generation'
|
||||
import AuthenticatedLayout from '../../components/authenticated-layout'
|
||||
|
||||
const Workflow = () => {
|
||||
return (
|
||||
<Main isWorkflow />
|
||||
<AuthenticatedLayout>
|
||||
<Main isWorkflow />
|
||||
</AuthenticatedLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
374
web/app/account/account-page/email-change-modal.tsx
Normal file
374
web/app/account/account-page/email-change-modal.tsx
Normal file
@@ -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'
|
||||
|
||||
const titleClassName = `
|
||||
system-sm-semibold text-text-secondary
|
||||
@@ -48,6 +48,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)
|
||||
@@ -123,10 +124,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>
|
||||
@@ -170,6 +178,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>
|
||||
{
|
||||
@@ -190,7 +203,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'
|
||||
/>
|
||||
@@ -202,7 +215,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>
|
||||
@@ -231,7 +244,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 && (
|
||||
@@ -316,6 +329,13 @@ export default function AccountPage() {
|
||||
/>
|
||||
)
|
||||
}
|
||||
{showUpdateEmail && (
|
||||
<EmailChangeModal
|
||||
show={showUpdateEmail}
|
||||
onClose={() => setShowUpdateEmail(false)}
|
||||
email={userProfile.email}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,8 @@ import type {
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type ChatWithHistoryContextValue = {
|
||||
appInfoError?: any
|
||||
appInfoLoading?: boolean
|
||||
appMeta?: AppMeta
|
||||
appData?: AppData
|
||||
userCanAccess?: boolean
|
||||
appMeta?: AppMeta | null
|
||||
appData?: AppData | null
|
||||
appParams?: ChatConfig
|
||||
appChatListDataLoading?: boolean
|
||||
currentConversationId: string
|
||||
@@ -62,7 +59,6 @@ export type ChatWithHistoryContextValue = {
|
||||
}
|
||||
|
||||
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
|
||||
userCanAccess: false,
|
||||
currentConversationId: '',
|
||||
appPrevChatTree: [],
|
||||
pinnedConversationList: [],
|
||||
|
||||
@@ -21,9 +21,6 @@ import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
import {
|
||||
delConversation,
|
||||
fetchAppInfo,
|
||||
fetchAppMeta,
|
||||
fetchAppParams,
|
||||
fetchChatList,
|
||||
fetchConversations,
|
||||
generationConversationName,
|
||||
@@ -43,8 +40,7 @@ import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
function getFormattedChatList(messages: any[]) {
|
||||
const newChatList: ChatItem[] = []
|
||||
@@ -74,13 +70,9 @@ function getFormattedChatList(messages: any[]) {
|
||||
|
||||
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
|
||||
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
|
||||
appId: installedAppInfo?.app.id || appInfo?.app_id,
|
||||
isInstalledApp,
|
||||
enabled: systemFeatures.webapp_auth.enabled,
|
||||
})
|
||||
const appInfo = useWebAppStore(s => s.appInfo)
|
||||
const appParams = useWebAppStore(s => s.appParams)
|
||||
const appMeta = useWebAppStore(s => s.appMeta)
|
||||
|
||||
useAppFavicon({
|
||||
enable: !installedAppInfo,
|
||||
@@ -107,6 +99,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
|
||||
},
|
||||
plan: 'basic',
|
||||
custom_config: null,
|
||||
} as AppData
|
||||
}
|
||||
|
||||
@@ -166,8 +159,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
return currentConversationId
|
||||
}, [currentConversationId, newConversationId])
|
||||
|
||||
const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams(isInstalledApp, appId))
|
||||
const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId))
|
||||
const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100))
|
||||
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
|
||||
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
|
||||
@@ -485,9 +476,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
}, [isInstalledApp, appId, t, notify])
|
||||
|
||||
return {
|
||||
appInfoError,
|
||||
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission),
|
||||
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
|
||||
isInstalledApp,
|
||||
appId,
|
||||
currentConversationId,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
@@ -19,12 +18,10 @@ import ChatWrapper from './chat-wrapper'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { checkOrSetAccessToken, removeAccessToken } from '@/app/components/share/utils'
|
||||
import { checkOrSetAccessToken } from '@/app/components/share/utils'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import cn from '@/utils/classnames'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
type ChatWithHistoryProps = {
|
||||
className?: string
|
||||
@@ -33,16 +30,12 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const {
|
||||
userCanAccess,
|
||||
appInfoError,
|
||||
appData,
|
||||
appInfoLoading,
|
||||
appChatListDataLoading,
|
||||
chatShouldReloadKey,
|
||||
isMobile,
|
||||
themeBuilder,
|
||||
sidebarCollapseState,
|
||||
isInstalledApp,
|
||||
} = useChatWithHistoryContext()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
const customConfig = appData?.custom_config
|
||||
@@ -56,41 +49,6 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
|
||||
useDocumentTitle(site?.title || 'Chat')
|
||||
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const getSigninUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('message')
|
||||
params.set('redirect_url', pathname)
|
||||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams, pathname])
|
||||
|
||||
const backToHome = useCallback(() => {
|
||||
removeAccessToken()
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
|
||||
if (appInfoLoading) {
|
||||
return (
|
||||
<Loading type='app' />
|
||||
)
|
||||
}
|
||||
if (!userCanAccess) {
|
||||
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
|
||||
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
|
||||
{!isInstalledApp && <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
if (appInfoError) {
|
||||
return (
|
||||
<AppUnavailable />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-full bg-background-default-burn',
|
||||
@@ -148,9 +106,6 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
const themeBuilder = useThemeContext()
|
||||
|
||||
const {
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
userCanAccess,
|
||||
appData,
|
||||
appParams,
|
||||
appMeta,
|
||||
@@ -191,10 +146,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
|
||||
return (
|
||||
<ChatWithHistoryContext.Provider value={{
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appData,
|
||||
userCanAccess,
|
||||
appParams,
|
||||
appMeta,
|
||||
appChatListDataLoading,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
'use client'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useAsyncEffect } from 'ahooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
EmbeddedChatbotContext,
|
||||
@@ -14,8 +11,6 @@ import { useEmbeddedChatbot } from './hooks'
|
||||
import { isDify } from './utils'
|
||||
import { useThemeContext } from './theme/theme-context'
|
||||
import { CssTransform } from './theme/utils'
|
||||
import { checkOrSetAccessToken, removeAccessToken } from '@/app/components/share/utils'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
|
||||
@@ -25,21 +20,16 @@ import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import cn from '@/utils/classnames'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
const Chatbot = () => {
|
||||
const {
|
||||
userCanAccess,
|
||||
isMobile,
|
||||
allowResetChat,
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appData,
|
||||
appChatListDataLoading,
|
||||
chatShouldReloadKey,
|
||||
handleNewConversation,
|
||||
themeBuilder,
|
||||
isInstalledApp,
|
||||
} = useEmbeddedChatbotContext()
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
@@ -55,58 +45,6 @@ const Chatbot = () => {
|
||||
|
||||
useDocumentTitle(site?.title || 'Chat')
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const getSigninUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('message')
|
||||
params.set('redirect_url', pathname)
|
||||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams, pathname])
|
||||
|
||||
const backToHome = useCallback(() => {
|
||||
removeAccessToken()
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
|
||||
if (appInfoLoading) {
|
||||
return (
|
||||
<>
|
||||
{!isMobile && <Loading type='app' />}
|
||||
{isMobile && (
|
||||
<div className={cn('relative')}>
|
||||
<div className={cn('flex h-[calc(100vh_-_60px)] flex-col rounded-2xl border-[0.5px] border-components-panel-border shadow-xs')}>
|
||||
<Loading type='app' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!userCanAccess) {
|
||||
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
|
||||
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
|
||||
{!isInstalledApp && <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
if (appInfoError) {
|
||||
return (
|
||||
<>
|
||||
{!isMobile && <AppUnavailable />}
|
||||
{isMobile && (
|
||||
<div className={cn('relative')}>
|
||||
<div className={cn('flex h-[calc(100vh_-_60px)] flex-col rounded-2xl border-[0.5px] border-components-panel-border shadow-xs')}>
|
||||
<AppUnavailable />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div
|
||||
@@ -162,8 +100,6 @@ const EmbeddedChatbotWrapper = () => {
|
||||
const themeBuilder = useThemeContext()
|
||||
|
||||
const {
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appData,
|
||||
userCanAccess,
|
||||
appParams,
|
||||
@@ -200,8 +136,6 @@ const EmbeddedChatbotWrapper = () => {
|
||||
|
||||
return <EmbeddedChatbotContext.Provider value={{
|
||||
userCanAccess,
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appData,
|
||||
appParams,
|
||||
appMeta,
|
||||
@@ -241,34 +175,6 @@ const EmbeddedChatbotWrapper = () => {
|
||||
}
|
||||
|
||||
const EmbeddedChatbot = () => {
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
|
||||
const [isUnknownReason, setIsUnknownReason] = useState<boolean>(false)
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!initialized) {
|
||||
try {
|
||||
await checkOrSetAccessToken()
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
else {
|
||||
setIsUnknownReason(true)
|
||||
setAppUnavailable(true)
|
||||
}
|
||||
}
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!initialized)
|
||||
return null
|
||||
|
||||
if (appUnavailable)
|
||||
return <AppUnavailable isUnknownReason={isUnknownReason} />
|
||||
|
||||
return <EmbeddedChatbotWrapper />
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,16 @@ export type ChatConfig = Omit<ModelConfig, 'model'> & {
|
||||
questionEditEnable?: boolean
|
||||
supportFeedback?: boolean
|
||||
supportCitationHitInfo?: boolean
|
||||
system_parameters: {
|
||||
audio_file_size_limit: number
|
||||
file_size_limit: number
|
||||
image_file_size_limit: number
|
||||
video_file_size_limit: number
|
||||
workflow_file_upload_limit: number
|
||||
}
|
||||
more_like_this: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkflowProcess = {
|
||||
|
||||
@@ -99,7 +99,8 @@ export type CurrentPlanInfoBackend = {
|
||||
workspace_members: {
|
||||
size: number
|
||||
limit: number
|
||||
}
|
||||
},
|
||||
is_allow_transfer_workspace: boolean
|
||||
}
|
||||
|
||||
export type SubscriptionItem = {
|
||||
|
||||
@@ -22,6 +22,7 @@ const Explore: FC<IExploreProps> = ({
|
||||
const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
const [hasEditPermission, setHasEditPermission] = useState(false)
|
||||
const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
|
||||
const [isFetchingInstalledApps, setIsFetchingInstalledApps] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useDocumentTitle(t('common.menus.explore'))
|
||||
@@ -51,6 +52,8 @@ const Explore: FC<IExploreProps> = ({
|
||||
hasEditPermission,
|
||||
installedApps,
|
||||
setInstalledApps,
|
||||
isFetchingInstalledApps,
|
||||
setIsFetchingInstalledApps,
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import TextGenerationApp from '@/app/components/share/text-generation'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import type { AppData } from '@/models/share'
|
||||
|
||||
export type IInstalledAppProps = {
|
||||
id: string
|
||||
@@ -14,26 +20,95 @@ export type IInstalledAppProps = {
|
||||
const InstalledApp: FC<IInstalledAppProps> = ({
|
||||
id,
|
||||
}) => {
|
||||
const { installedApps } = useContext(ExploreContext)
|
||||
const { installedApps, isFetchingInstalledApps } = useContext(ExploreContext)
|
||||
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
|
||||
const installedApp = installedApps.find(item => item.id === id)
|
||||
const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode)
|
||||
const updateAppParams = useWebAppStore(s => s.updateAppParams)
|
||||
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
|
||||
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
|
||||
const { isFetching: isFetchingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null)
|
||||
const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null)
|
||||
const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null)
|
||||
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: installedApp?.app.id, isInstalledApp: true })
|
||||
|
||||
if (!installedApp) {
|
||||
return (
|
||||
<div className='flex h-full items-center'>
|
||||
<Loading type='area' />
|
||||
</div>
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!installedApp) {
|
||||
updateAppInfo(null)
|
||||
}
|
||||
else {
|
||||
const { id, app } = installedApp
|
||||
updateAppInfo({
|
||||
app_id: id,
|
||||
site: {
|
||||
title: app.name,
|
||||
icon_type: app.icon_type,
|
||||
icon: app.icon,
|
||||
icon_background: app.icon_background,
|
||||
icon_url: app.icon_url,
|
||||
prompt_public: false,
|
||||
copyright: '',
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
|
||||
},
|
||||
plan: 'basic',
|
||||
custom_config: null,
|
||||
} as AppData)
|
||||
}
|
||||
|
||||
if (appParams)
|
||||
updateAppParams(appParams)
|
||||
if (appMeta)
|
||||
updateWebAppMeta(appMeta)
|
||||
if (webAppAccessMode)
|
||||
updateWebAppAccessMode(webAppAccessMode.accessMode)
|
||||
updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result))
|
||||
}, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode])
|
||||
|
||||
if (appParamsError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={appParamsError.message} />
|
||||
</div>
|
||||
}
|
||||
if (appMetaError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={appMetaError.message} />
|
||||
</div>
|
||||
}
|
||||
if (useCanAccessAppError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={useCanAccessAppError.message} />
|
||||
</div>
|
||||
}
|
||||
if (webAppAccessModeError) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable unknownReason={webAppAccessModeError.message} />
|
||||
</div>
|
||||
}
|
||||
if (userCanAccessApp && !userCanAccessApp.result) {
|
||||
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
|
||||
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
|
||||
</div>
|
||||
}
|
||||
if (isFetchingAppParams || isFetchingAppMeta || isFetchingWebAppAccessMode || isFetchingInstalledApps) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
if (!installedApp) {
|
||||
return <div className='flex h-full items-center justify-center'>
|
||||
<AppUnavailable code={404} isUnknownReason />
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-full bg-background-default py-2 pl-0 pr-2 sm:p-2'>
|
||||
{installedApp.app.mode !== 'completion' && installedApp.app.mode !== 'workflow' && (
|
||||
{installedApp?.app.mode !== 'completion' && installedApp?.app.mode !== 'workflow' && (
|
||||
<ChatWithHistory installedAppInfo={installedApp} className='overflow-hidden rounded-2xl shadow-md' />
|
||||
)}
|
||||
{installedApp.app.mode === 'completion' && (
|
||||
{installedApp?.app.mode === 'completion' && (
|
||||
<TextGenerationApp isInstalledApp installedAppInfo={installedApp} />
|
||||
)}
|
||||
{installedApp.app.mode === 'workflow' && (
|
||||
{installedApp?.app.mode === 'workflow' && (
|
||||
<TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,11 @@ import Link from 'next/link'
|
||||
import Toast from '../../base/toast'
|
||||
import Item from './app-nav-item'
|
||||
import cn from '@/utils/classnames'
|
||||
import { fetchInstalledAppList as doFetchInstalledAppList, uninstallApp, updatePinStatus } from '@/service/explore'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
|
||||
|
||||
const SelectedDiscoveryIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="current" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -50,16 +50,14 @@ const SideBar: FC<IExploreSideBarProps> = ({
|
||||
const lastSegment = segments.slice(-1)[0]
|
||||
const isDiscoverySelected = lastSegment === 'apps'
|
||||
const isChatSelected = lastSegment === 'chat'
|
||||
const { installedApps, setInstalledApps } = useContext(ExploreContext)
|
||||
const { installedApps, setInstalledApps, setIsFetchingInstalledApps } = useContext(ExploreContext)
|
||||
const { isFetching: isFetchingInstalledApps, data: ret, refetch: fetchInstalledAppList } = useGetInstalledApps()
|
||||
const { mutateAsync: uninstallApp } = useUninstallApp()
|
||||
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
|
||||
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
const fetchInstalledAppList = async () => {
|
||||
const { installed_apps }: any = await doFetchInstalledAppList()
|
||||
setInstalledApps(installed_apps)
|
||||
}
|
||||
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [currId, setCurrId] = useState('')
|
||||
const handleDelete = async () => {
|
||||
@@ -70,25 +68,31 @@ const SideBar: FC<IExploreSideBarProps> = ({
|
||||
type: 'success',
|
||||
message: t('common.api.remove'),
|
||||
})
|
||||
fetchInstalledAppList()
|
||||
}
|
||||
|
||||
const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
|
||||
await updatePinStatus(id, isPinned)
|
||||
await updatePinStatus({ appId: id, isPinned })
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.success'),
|
||||
})
|
||||
fetchInstalledAppList()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstalledAppList()
|
||||
}, [])
|
||||
const installed_apps = (ret as any)?.installed_apps
|
||||
if (installed_apps && installed_apps.length > 0)
|
||||
setInstalledApps(installed_apps)
|
||||
else
|
||||
setInstalledApps([])
|
||||
}, [ret, setInstalledApps])
|
||||
|
||||
useEffect(() => {
|
||||
setIsFetchingInstalledApps(isFetchingInstalledApps)
|
||||
}, [isFetchingInstalledApps, setIsFetchingInstalledApps])
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstalledAppList()
|
||||
}, [controlUpdateInstalledApps])
|
||||
}, [controlUpdateInstalledApps, fetchInstalledAppList])
|
||||
|
||||
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
@@ -136,7 +136,7 @@ const PluginPage = ({
|
||||
const options = usePluginPageContext(v => v.options)
|
||||
const activeTab = usePluginPageContext(v => v.activeTab)
|
||||
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
|
||||
const { enable_marketplace, branding } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab])
|
||||
const isExploringMarketplace = useMemo(() => {
|
||||
@@ -225,7 +225,7 @@ const PluginPage = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
canSetPermissions && !branding.enabled && (
|
||||
canSetPermissions && (
|
||||
<Tooltip
|
||||
popupContent={t('plugin.privilege.title')}
|
||||
>
|
||||
|
||||
@@ -7,16 +7,14 @@ import {
|
||||
RiErrorWarningFill,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import TabHeader from '../../base/tab-header'
|
||||
import { checkOrSetAccessToken, removeAccessToken } from '../utils'
|
||||
import MenuDropdown from './menu-dropdown'
|
||||
import RunBatch from './run-batch'
|
||||
import ResDownload from './run-batch/res-download'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import RunOnce from '@/app/components/share/text-generation/run-once'
|
||||
import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share'
|
||||
import { fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type {
|
||||
MoreLikeThisConfig,
|
||||
@@ -39,10 +37,10 @@ import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group.
|
||||
enum TaskStatus {
|
||||
@@ -83,9 +81,6 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
const mode = searchParams.get('mode') || 'create'
|
||||
const [currentTab, setCurrentTab] = useState<string>(['create', 'batch'].includes(mode) ? mode : 'create')
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
// Notice this situation isCallBatchAPI but not in batch tab
|
||||
const [isCallBatchAPI, setIsCallBatchAPI] = useState(false)
|
||||
const isInBatchTab = currentTab === 'batch'
|
||||
@@ -103,30 +98,19 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
|
||||
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
|
||||
|
||||
const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({
|
||||
appId,
|
||||
isInstalledApp,
|
||||
enabled: systemFeatures.webapp_auth.enabled,
|
||||
})
|
||||
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
|
||||
appId,
|
||||
isInstalledApp,
|
||||
enabled: systemFeatures.webapp_auth.enabled,
|
||||
})
|
||||
|
||||
// save message
|
||||
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
|
||||
const fetchSavedMessage = async () => {
|
||||
const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id)
|
||||
const fetchSavedMessage = useCallback(async () => {
|
||||
const res: any = await doFetchSavedMessage(isInstalledApp, appId)
|
||||
setSavedMessages(res.data)
|
||||
}
|
||||
}, [isInstalledApp, appId])
|
||||
const handleSaveMessage = async (messageId: string) => {
|
||||
await saveMessage(messageId, isInstalledApp, installedAppInfo?.id)
|
||||
await saveMessage(messageId, isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.saved') })
|
||||
fetchSavedMessage()
|
||||
}
|
||||
const handleRemoveSavedMessage = async (messageId: string) => {
|
||||
await removeMessage(messageId, isInstalledApp, installedAppInfo?.id)
|
||||
await removeMessage(messageId, isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.remove') })
|
||||
fetchSavedMessage()
|
||||
}
|
||||
@@ -375,34 +359,14 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const fetchInitData = async () => {
|
||||
if (!isInstalledApp)
|
||||
await checkOrSetAccessToken()
|
||||
|
||||
return Promise.all([
|
||||
isInstalledApp
|
||||
? {
|
||||
app_id: installedAppInfo?.id,
|
||||
site: {
|
||||
title: installedAppInfo?.app.name,
|
||||
prompt_public: false,
|
||||
copyright: '',
|
||||
icon: installedAppInfo?.app.icon,
|
||||
icon_background: installedAppInfo?.app.icon_background,
|
||||
},
|
||||
plan: 'basic',
|
||||
}
|
||||
: fetchAppInfo(),
|
||||
fetchAppParams(isInstalledApp, installedAppInfo?.id),
|
||||
!isWorkflow
|
||||
? fetchSavedMessage()
|
||||
: {},
|
||||
])
|
||||
}
|
||||
|
||||
const appData = useWebAppStore(s => s.appInfo)
|
||||
const appParams = useWebAppStore(s => s.appParams)
|
||||
const accessMode = useWebAppStore(s => s.webAppAccessMode)
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [appData, appParams]: any = await fetchInitData()
|
||||
if (!appData || !appParams)
|
||||
return
|
||||
!isWorkflow && fetchSavedMessage()
|
||||
const { app_id: appId, site: siteInfo, custom_config } = appData
|
||||
setAppId(appId)
|
||||
setSiteInfo(siteInfo as SiteInfo)
|
||||
@@ -413,11 +377,11 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
setVisionConfig({
|
||||
// legacy of image upload compatible
|
||||
...file_upload,
|
||||
transfer_methods: file_upload.allowed_file_upload_methods || file_upload.allowed_upload_methods,
|
||||
transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods,
|
||||
// legacy of image upload compatible
|
||||
image_file_size_limit: appParams?.system_parameters?.image_file_size_limit,
|
||||
image_file_size_limit: appParams?.system_parameters.image_file_size_limit,
|
||||
fileUploadConfig: appParams?.system_parameters,
|
||||
})
|
||||
} as any)
|
||||
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
|
||||
setPromptConfig({
|
||||
prompt_template: '', // placeholder for future
|
||||
@@ -426,7 +390,7 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
setMoreLikeThisConfig(more_like_this)
|
||||
setTextToSpeechConfig(text_to_speech)
|
||||
})()
|
||||
}, [])
|
||||
}, [appData, appParams, fetchSavedMessage, isWorkflow])
|
||||
|
||||
// Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
|
||||
useDocumentTitle(siteInfo?.title || t('share.generation.title'))
|
||||
@@ -528,32 +492,12 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
const getSigninUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('message')
|
||||
params.set('redirect_url', pathname)
|
||||
return `/webapp-signin?${params.toString()}`
|
||||
}, [searchParams, pathname])
|
||||
|
||||
const backToHome = useCallback(() => {
|
||||
removeAccessToken()
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
|
||||
if (!appId || !siteInfo || !promptConfig || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission))) {
|
||||
if (!appId || !siteInfo || !promptConfig) {
|
||||
return (
|
||||
<div className='flex h-screen items-center'>
|
||||
<Loading type='app' />
|
||||
</div>)
|
||||
}
|
||||
if (systemFeatures.webapp_auth.enabled && !userCanAccessResult?.result) {
|
||||
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
|
||||
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
|
||||
{!isInstalledApp && <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'bg-background-default-burn',
|
||||
@@ -578,7 +522,7 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
imageUrl={siteInfo.icon_url}
|
||||
/>
|
||||
<div className='system-md-semibold grow truncate text-text-secondary'>{siteInfo.title}</div>
|
||||
<MenuDropdown hideLogout={isInstalledApp || appAccessMode?.accessMode === AccessMode.PUBLIC} data={siteInfo} />
|
||||
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} data={siteInfo} />
|
||||
</div>
|
||||
{siteInfo.description && (
|
||||
<div className='system-xs-regular text-text-tertiary'>{siteInfo.description}</div>
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
type Props = {
|
||||
data?: SiteInfo
|
||||
@@ -32,7 +32,7 @@ const MenuDropdown: FC<Props> = ({
|
||||
placement,
|
||||
hideLogout,
|
||||
}) => {
|
||||
const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode)
|
||||
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -10,7 +10,7 @@ export const getInitialTokenV2 = (): Record<string, any> => ({
|
||||
version: 2,
|
||||
})
|
||||
|
||||
export const checkOrSetAccessToken = async (appCode?: string) => {
|
||||
export const checkOrSetAccessToken = async (appCode?: string | null) => {
|
||||
const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0]
|
||||
const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id
|
||||
const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2())
|
||||
|
||||
@@ -8,6 +8,8 @@ type IExplore = {
|
||||
hasEditPermission: boolean
|
||||
installedApps: InstalledApp[]
|
||||
setInstalledApps: (installedApps: InstalledApp[]) => void
|
||||
isFetchingInstalledApps: boolean
|
||||
setIsFetchingInstalledApps: (isFetchingInstalledApps: boolean) => void
|
||||
}
|
||||
|
||||
const ExploreContext = createContext<IExplore>({
|
||||
@@ -16,6 +18,8 @@ const ExploreContext = createContext<IExplore>({
|
||||
hasEditPermission: false,
|
||||
installedApps: [],
|
||||
setInstalledApps: noop,
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: noop,
|
||||
})
|
||||
|
||||
export default ExploreContext
|
||||
|
||||
@@ -7,15 +7,12 @@ import type { SystemFeatures } from '@/types/feature'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { getSystemFeatures } from '@/service/common'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
|
||||
type GlobalPublicStore = {
|
||||
isGlobalPending: boolean
|
||||
setIsGlobalPending: (isPending: boolean) => void
|
||||
systemFeatures: SystemFeatures
|
||||
setSystemFeatures: (systemFeatures: SystemFeatures) => void
|
||||
webAppAccessMode: AccessMode,
|
||||
setWebAppAccessMode: (webAppAccessMode: AccessMode) => void
|
||||
}
|
||||
|
||||
export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
|
||||
@@ -23,8 +20,6 @@ export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
|
||||
setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })),
|
||||
systemFeatures: defaultSystemFeatures,
|
||||
setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })),
|
||||
webAppAccessMode: AccessMode.PUBLIC,
|
||||
setWebAppAccessMode: (webAppAccessMode: AccessMode) => set(() => ({ webAppAccessMode })),
|
||||
}))
|
||||
|
||||
const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
87
web/context/web-app-context.tsx
Normal file
87
web/context/web-app-context.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import type { AppData, AppMeta } from '@/models/share'
|
||||
import { useGetWebAppAccessModeByCode } from '@/service/use-share'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type WebAppStore = {
|
||||
shareCode: string | null
|
||||
updateShareCode: (shareCode: string | null) => void
|
||||
appInfo: AppData | null
|
||||
updateAppInfo: (appInfo: AppData | null) => void
|
||||
appParams: ChatConfig | null
|
||||
updateAppParams: (appParams: ChatConfig | null) => void
|
||||
webAppAccessMode: AccessMode
|
||||
updateWebAppAccessMode: (accessMode: AccessMode) => void
|
||||
appMeta: AppMeta | null
|
||||
updateWebAppMeta: (appMeta: AppMeta | null) => void
|
||||
userCanAccessApp: boolean
|
||||
updateUserCanAccessApp: (canAccess: boolean) => void
|
||||
}
|
||||
|
||||
export const useWebAppStore = create<WebAppStore>(set => ({
|
||||
shareCode: null,
|
||||
updateShareCode: (shareCode: string | null) => set(() => ({ shareCode })),
|
||||
appInfo: null,
|
||||
updateAppInfo: (appInfo: AppData | null) => set(() => ({ appInfo })),
|
||||
appParams: null,
|
||||
updateAppParams: (appParams: ChatConfig | null) => set(() => ({ appParams })),
|
||||
webAppAccessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
updateWebAppAccessMode: (accessMode: AccessMode) => set(() => ({ webAppAccessMode: accessMode })),
|
||||
appMeta: null,
|
||||
updateWebAppMeta: (appMeta: AppMeta | null) => set(() => ({ appMeta })),
|
||||
userCanAccessApp: false,
|
||||
updateUserCanAccessApp: (canAccess: boolean) => set(() => ({ userCanAccessApp: canAccess })),
|
||||
}))
|
||||
|
||||
const getShareCodeFromRedirectUrl = (redirectUrl: string | null): string | null => {
|
||||
if (!redirectUrl || redirectUrl.length === 0)
|
||||
return null
|
||||
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
|
||||
return url.pathname.split('/').pop() || null
|
||||
}
|
||||
const getShareCodeFromPathname = (pathname: string): string | null => {
|
||||
const code = pathname.split('/').pop() || null
|
||||
if (code === 'webapp-signin')
|
||||
return null
|
||||
return code
|
||||
}
|
||||
|
||||
const WebAppStoreProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode)
|
||||
const updateShareCode = useWebAppStore(state => state.updateShareCode)
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectUrlParam = searchParams.get('redirect_url')
|
||||
const [shareCode, setShareCode] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
const shareCodeFromRedirect = getShareCodeFromRedirectUrl(redirectUrlParam)
|
||||
const shareCodeFromPathname = getShareCodeFromPathname(pathname)
|
||||
const newShareCode = shareCodeFromRedirect || shareCodeFromPathname
|
||||
setShareCode(newShareCode)
|
||||
updateShareCode(newShareCode)
|
||||
}, [pathname, redirectUrlParam, updateShareCode])
|
||||
const { isFetching, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode)
|
||||
useEffect(() => {
|
||||
if (accessModeResult?.accessMode)
|
||||
updateWebAppAccessMode(accessModeResult.accessMode)
|
||||
}, [accessModeResult, updateWebAppAccessMode])
|
||||
if (isFetching) {
|
||||
return <div className='flex h-full w-full items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default WebAppStoreProvider
|
||||
@@ -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',
|
||||
|
||||
@@ -105,6 +105,7 @@ const translation = {
|
||||
licenseInactive: 'License Inactive',
|
||||
licenseInactiveTip: 'The Dify Enterprise license for your workspace is inactive. Please contact your administrator to continue using Dify.',
|
||||
webapp: {
|
||||
login: 'Login',
|
||||
noLoginMethod: 'Authentication method not configured for web app',
|
||||
noLoginMethodTip: 'Please contact the system admin to add an authentication method.',
|
||||
disabled: 'Webapp authentication is disabled. Please contact the system admin to enable it. You can try to use the app directly.',
|
||||
|
||||
@@ -106,6 +106,7 @@ const translation = {
|
||||
licenseExpired: 'ライセンスの有効期限が切れています',
|
||||
licenseLostTip: 'Dify ライセンスサーバーへの接続に失敗しました。続けて Dify を使用するために管理者に連絡してください。',
|
||||
webapp: {
|
||||
login: 'ログイン',
|
||||
noLoginMethod: 'Web アプリに対して認証方法が構成されていません',
|
||||
noLoginMethodTip: 'システム管理者に連絡して、認証方法を追加してください。',
|
||||
disabled: 'Web アプリの認証が無効になっています。システム管理者に連絡して有効にしてください。直接アプリを使用してみてください。',
|
||||
|
||||
@@ -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: '登录方式',
|
||||
|
||||
@@ -106,6 +106,7 @@ const translation = {
|
||||
licenseInactive: '许可证未激活',
|
||||
licenseInactiveTip: '您所在空间的 Dify Enterprise 许可证尚未激活,请联系管理员以继续使用 Dify。',
|
||||
webapp: {
|
||||
login: '登录',
|
||||
noLoginMethod: 'Web 应用未配置身份认证方式',
|
||||
noLoginMethodTip: '请联系系统管理员添加身份认证方式',
|
||||
disabled: 'Web 应用身份认证已禁用,请联系系统管理员启用。您也可以尝试直接使用应用。',
|
||||
|
||||
@@ -35,7 +35,7 @@ export type AppMeta = {
|
||||
export type AppData = {
|
||||
app_id: string
|
||||
can_replace_logo?: boolean
|
||||
custom_config?: Record<string, any>
|
||||
custom_config: Record<string, any> | null
|
||||
enable_site?: boolean
|
||||
end_user_id?: string
|
||||
site: SiteInfo
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { get, post } from './base'
|
||||
import { getAppAccessMode, getUserCanAccess } from './share'
|
||||
import { getUserCanAccess } from './share'
|
||||
import type { AccessControlAccount, AccessControlGroup, AccessMode, Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
const NAME_SPACE = 'access-control'
|
||||
|
||||
@@ -69,25 +70,18 @@ export const useUpdateAccessMode = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetAppAccessMode = ({ appId, isInstalledApp = true, enabled }: { appId?: string; isInstalledApp?: boolean; enabled: boolean }) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'app-access-mode', appId],
|
||||
queryFn: () => getAppAccessMode(appId!, isInstalledApp),
|
||||
enabled: !!appId && enabled,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetUserCanAccessApp = ({ appId, isInstalledApp = true, enabled }: { appId?: string; isInstalledApp?: boolean; enabled: boolean }) => {
|
||||
export const useGetUserCanAccessApp = ({ appId, isInstalledApp = true }: { appId?: string; isInstalledApp?: boolean; }) => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'user-can-access-app', appId],
|
||||
queryFn: () => getUserCanAccess(appId!, isInstalledApp),
|
||||
enabled: !!appId && enabled,
|
||||
queryFn: () => {
|
||||
if (systemFeatures.webapp_auth.enabled)
|
||||
return getUserCanAccess(appId!, isInstalledApp)
|
||||
else
|
||||
return { result: true }
|
||||
},
|
||||
enabled: !!appId,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
initialData: {
|
||||
result: !enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -413,7 +413,7 @@ export const ssePost = async (
|
||||
|
||||
if (data.code === 'unauthorized') {
|
||||
removeAccessToken()
|
||||
globalThis.location.reload()
|
||||
requiredWebSSOLogin()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -507,7 +507,7 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
|
||||
} = otherOptionsForBaseFetch
|
||||
if (isPublicAPI && code === 'unauthorized') {
|
||||
removeAccessToken()
|
||||
globalThis.location.reload()
|
||||
requiredWebSSOLogin()
|
||||
return Promise.reject(err)
|
||||
}
|
||||
if (code === 'init_validate_failed' && IS_CE_EDITION && !silent) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { del, get, patch, post } from './base'
|
||||
import type { App, AppCategory } from '@/models/explore'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
|
||||
export const fetchAppList = () => {
|
||||
return get<{
|
||||
@@ -39,3 +40,7 @@ export const updatePinStatus = (id: string, isPinned: boolean) => {
|
||||
export const getToolProviders = () => {
|
||||
return get('/workspaces/current/tool-providers')
|
||||
}
|
||||
|
||||
export const getAppAccessModeByAppId = (appId: string) => {
|
||||
return get<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`)
|
||||
}
|
||||
|
||||
@@ -296,13 +296,6 @@ export const fetchAccessToken = async ({ appCode, userId, webAppAccessToken }: {
|
||||
return get(url, { headers }) as Promise<{ access_token: string }>
|
||||
}
|
||||
|
||||
export const getAppAccessMode = (appId: string, isInstalledApp: boolean) => {
|
||||
if (isInstalledApp)
|
||||
return consoleGet<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`)
|
||||
|
||||
return get<{ accessMode: AccessMode }>(`/webapp/access-mode?appId=${appId}`)
|
||||
}
|
||||
|
||||
export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => {
|
||||
if (isInstalledApp)
|
||||
return consoleGet<{ result: boolean }>(`/enterprise/webapp/permission?appId=${appId}`)
|
||||
|
||||
81
web/service/use-explore.ts
Normal file
81
web/service/use-explore.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore'
|
||||
import { fetchAppMeta, fetchAppParams } from './share'
|
||||
|
||||
const NAME_SPACE = 'explore'
|
||||
|
||||
export const useGetInstalledApps = () => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'installedApps'],
|
||||
queryFn: () => {
|
||||
return fetchInstalledAppList()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUninstallApp = () => {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'uninstallApp'],
|
||||
mutationFn: (appId: string) => uninstallApp(appId),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateAppPinStatus = () => {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'updateAppPinStatus'],
|
||||
mutationFn: ({ appId, isPinned }: { appId: string; isPinned: boolean }) => updatePinStatus(appId, isPinned),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'appAccessMode', appId],
|
||||
queryFn: () => {
|
||||
if (systemFeatures.webapp_auth.enabled === false) {
|
||||
return {
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
}
|
||||
}
|
||||
if (!appId || appId.length === 0)
|
||||
return Promise.reject(new Error('App code is required to get access mode'))
|
||||
|
||||
return getAppAccessModeByAppId(appId)
|
||||
},
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledAppParams = (appId: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'appParams', appId],
|
||||
queryFn: () => {
|
||||
if (!appId || appId.length === 0)
|
||||
return Promise.reject(new Error('App ID is required to get app params'))
|
||||
return fetchAppParams(true, appId)
|
||||
},
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetInstalledAppMeta = (appId: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'appMeta', appId],
|
||||
queryFn: () => {
|
||||
if (!appId || appId.length === 0)
|
||||
return Promise.reject(new Error('App ID is required to get app meta'))
|
||||
return fetchAppMeta(true, appId)
|
||||
},
|
||||
enabled: !!appId,
|
||||
})
|
||||
}
|
||||
@@ -1,17 +1,52 @@
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getAppAccessModeByAppCode } from './share'
|
||||
import { fetchAppInfo, fetchAppMeta, fetchAppParams, getAppAccessModeByAppCode } from './share'
|
||||
|
||||
const NAME_SPACE = 'webapp'
|
||||
|
||||
export const useAppAccessModeByCode = (code: string | null) => {
|
||||
export const useGetWebAppAccessModeByCode = (code: string | null) => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'appAccessMode', code],
|
||||
queryFn: () => {
|
||||
if (!code)
|
||||
return null
|
||||
if (systemFeatures.webapp_auth.enabled === false) {
|
||||
return {
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
}
|
||||
}
|
||||
if (!code || code.length === 0)
|
||||
return Promise.reject(new Error('App code is required to get access mode'))
|
||||
|
||||
return getAppAccessModeByAppCode(code)
|
||||
},
|
||||
enabled: !!code,
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetWebAppInfo = () => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'appInfo'],
|
||||
queryFn: () => {
|
||||
return fetchAppInfo()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetWebAppParams = () => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'appParams'],
|
||||
queryFn: () => {
|
||||
return fetchAppParams(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetWebAppMeta = () => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'appMeta'],
|
||||
queryFn: () => {
|
||||
return fetchAppMeta(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user