mirror of
https://github.com/langgenius/dify.git
synced 2024-11-16 03:32:23 +08:00
feat: implement forgot password feature (#5534)
This commit is contained in:
parent
f546db5437
commit
00b4cc3cd4
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -174,3 +174,5 @@ sdks/python-client/dify_client.egg-info
|
|||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
pyrightconfig.json
|
||||
|
||||
.idea/
|
||||
|
|
|
@ -17,6 +17,10 @@ class SecurityConfig(BaseModel):
|
|||
default=None,
|
||||
)
|
||||
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_HOURS: PositiveInt = Field(
|
||||
description='Expiry time in hours for reset token',
|
||||
default=24,
|
||||
)
|
||||
|
||||
class AppExecutionConfig(BaseModel):
|
||||
"""
|
||||
|
|
|
@ -30,7 +30,7 @@ from .app import (
|
|||
)
|
||||
|
||||
# Import auth controllers
|
||||
from .auth import activate, data_source_bearer_auth, data_source_oauth, login, oauth
|
||||
from .auth import activate, data_source_bearer_auth, data_source_oauth, forgot_password, login, oauth
|
||||
|
||||
# Import billing controllers
|
||||
from .billing import billing
|
||||
|
|
|
@ -5,3 +5,28 @@ class ApiKeyAuthFailedError(BaseHTTPException):
|
|||
error_code = 'auth_failed'
|
||||
description = "{message}"
|
||||
code = 500
|
||||
|
||||
|
||||
class InvalidEmailError(BaseHTTPException):
|
||||
error_code = 'invalid_email'
|
||||
description = "The email address is not valid."
|
||||
code = 400
|
||||
|
||||
|
||||
class PasswordMismatchError(BaseHTTPException):
|
||||
error_code = 'password_mismatch'
|
||||
description = "The passwords do not match."
|
||||
code = 400
|
||||
|
||||
|
||||
class InvalidTokenError(BaseHTTPException):
|
||||
error_code = 'invalid_or_expired_token'
|
||||
description = "The token is invalid or has expired."
|
||||
code = 400
|
||||
|
||||
|
||||
class PasswordResetRateLimitExceededError(BaseHTTPException):
|
||||
error_code = 'password_reset_rate_limit_exceeded'
|
||||
description = "Password reset rate limit exceeded. Try again later."
|
||||
code = 429
|
||||
|
||||
|
|
107
api/controllers/console/auth/forgot_password.py
Normal file
107
api/controllers/console/auth/forgot_password.py
Normal file
|
@ -0,0 +1,107 @@
|
|||
import base64
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from flask_restful import Resource, reqparse
|
||||
|
||||
from controllers.console import api
|
||||
from controllers.console.auth.error import (
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
PasswordMismatchError,
|
||||
PasswordResetRateLimitExceededError,
|
||||
)
|
||||
from controllers.console.setup import setup_required
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import email as email_validate
|
||||
from libs.password import hash_password, valid_password
|
||||
from models.account import Account
|
||||
from services.account_service import AccountService
|
||||
from services.errors.account import RateLimitExceededError
|
||||
|
||||
|
||||
class ForgotPasswordSendEmailApi(Resource):
|
||||
|
||||
@setup_required
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('email', type=str, required=True, location='json')
|
||||
args = parser.parse_args()
|
||||
|
||||
email = args['email']
|
||||
|
||||
if not email_validate(email):
|
||||
raise InvalidEmailError()
|
||||
|
||||
account = Account.query.filter_by(email=email).first()
|
||||
|
||||
if account:
|
||||
try:
|
||||
AccountService.send_reset_password_email(account=account)
|
||||
except RateLimitExceededError:
|
||||
logging.warning(f"Rate limit exceeded for email: {account.email}")
|
||||
raise PasswordResetRateLimitExceededError()
|
||||
else:
|
||||
# Return success to avoid revealing email registration status
|
||||
logging.warning(f"Attempt to reset password for unregistered email: {email}")
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
class ForgotPasswordCheckApi(Resource):
|
||||
|
||||
@setup_required
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('token', type=str, required=True, nullable=False, location='json')
|
||||
args = parser.parse_args()
|
||||
token = args['token']
|
||||
|
||||
reset_data = AccountService.get_reset_password_data(token)
|
||||
|
||||
if reset_data is None:
|
||||
return {'is_valid': False, 'email': None}
|
||||
return {'is_valid': True, 'email': reset_data.get('email')}
|
||||
|
||||
|
||||
class ForgotPasswordResetApi(Resource):
|
||||
|
||||
@setup_required
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument('token', type=str, required=True, nullable=False, location='json')
|
||||
parser.add_argument('new_password', type=valid_password, required=True, nullable=False, location='json')
|
||||
parser.add_argument('password_confirm', type=valid_password, required=True, nullable=False, location='json')
|
||||
args = parser.parse_args()
|
||||
|
||||
new_password = args['new_password']
|
||||
password_confirm = args['password_confirm']
|
||||
|
||||
if str(new_password).strip() != str(password_confirm).strip():
|
||||
raise PasswordMismatchError()
|
||||
|
||||
token = args['token']
|
||||
reset_data = AccountService.get_reset_password_data(token)
|
||||
|
||||
if reset_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
AccountService.revoke_reset_password_token(token)
|
||||
|
||||
salt = secrets.token_bytes(16)
|
||||
base64_salt = base64.b64encode(salt).decode()
|
||||
|
||||
password_hashed = hash_password(new_password, salt)
|
||||
base64_password_hashed = base64.b64encode(password_hashed).decode()
|
||||
|
||||
account = Account.query.filter_by(email=reset_data.get('email')).first()
|
||||
account.password = base64_password_hashed
|
||||
account.password_salt = base64_salt
|
||||
db.session.commit()
|
||||
|
||||
return {'result': 'success'}
|
||||
|
||||
|
||||
api.add_resource(ForgotPasswordSendEmailApi, '/forgot-password')
|
||||
api.add_resource(ForgotPasswordCheckApi, '/forgot-password/validity')
|
||||
api.add_resource(ForgotPasswordResetApi, '/forgot-password/resets')
|
|
@ -245,6 +245,8 @@ class AccountIntegrateApi(Resource):
|
|||
return {'data': integrate_data}
|
||||
|
||||
|
||||
|
||||
|
||||
# Register API resources
|
||||
api.add_resource(AccountInitApi, '/account/init')
|
||||
api.add_resource(AccountProfileApi, '/account/profile')
|
||||
|
|
|
@ -1,18 +1,23 @@
|
|||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from typing import Union
|
||||
from typing import Any, Optional, Union
|
||||
from zoneinfo import available_timezones
|
||||
|
||||
from flask import Response, stream_with_context
|
||||
from flask import Response, current_app, stream_with_context
|
||||
from flask_restful import fields
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.account import Account
|
||||
|
||||
|
||||
def run(script):
|
||||
return subprocess.getstatusoutput('source /root/.bashrc && ' + script)
|
||||
|
@ -46,12 +51,12 @@ def uuid_value(value):
|
|||
error = ('{value} is not a valid uuid.'
|
||||
.format(value=value))
|
||||
raise ValueError(error)
|
||||
|
||||
|
||||
def alphanumeric(value: str):
|
||||
# check if the value is alphanumeric and underlined
|
||||
if re.match(r'^[a-zA-Z0-9_]+$', value):
|
||||
return value
|
||||
|
||||
|
||||
raise ValueError(f'{value} is not a valid alphanumeric value')
|
||||
|
||||
def timestamp_value(timestamp):
|
||||
|
@ -163,3 +168,97 @@ def compact_generate_response(response: Union[dict, Generator]) -> Response:
|
|||
|
||||
return Response(stream_with_context(generate()), status=200,
|
||||
mimetype='text/event-stream')
|
||||
|
||||
|
||||
class TokenManager:
|
||||
|
||||
@classmethod
|
||||
def generate_token(cls, account: Account, token_type: str, additional_data: dict = None) -> str:
|
||||
old_token = cls._get_current_token_for_account(account.id, token_type)
|
||||
if old_token:
|
||||
if isinstance(old_token, bytes):
|
||||
old_token = old_token.decode('utf-8')
|
||||
cls.revoke_token(old_token, token_type)
|
||||
|
||||
token = str(uuid.uuid4())
|
||||
token_data = {
|
||||
'account_id': account.id,
|
||||
'email': account.email,
|
||||
'token_type': token_type
|
||||
}
|
||||
if additional_data:
|
||||
token_data.update(additional_data)
|
||||
|
||||
expiry_hours = current_app.config[f'{token_type.upper()}_TOKEN_EXPIRY_HOURS']
|
||||
token_key = cls._get_token_key(token, token_type)
|
||||
redis_client.setex(
|
||||
token_key,
|
||||
expiry_hours * 60 * 60,
|
||||
json.dumps(token_data)
|
||||
)
|
||||
|
||||
cls._set_current_token_for_account(account.id, token, token_type, expiry_hours)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def _get_token_key(cls, token: str, token_type: str) -> str:
|
||||
return f'{token_type}:token:{token}'
|
||||
|
||||
@classmethod
|
||||
def revoke_token(cls, token: str, token_type: str):
|
||||
token_key = cls._get_token_key(token, token_type)
|
||||
redis_client.delete(token_key)
|
||||
|
||||
@classmethod
|
||||
def get_token_data(cls, token: str, token_type: str) -> Optional[dict[str, Any]]:
|
||||
key = cls._get_token_key(token, token_type)
|
||||
token_data_json = redis_client.get(key)
|
||||
if token_data_json is None:
|
||||
logging.warning(f"{token_type} token {token} not found with key {key}")
|
||||
return None
|
||||
token_data = json.loads(token_data_json)
|
||||
return token_data
|
||||
|
||||
@classmethod
|
||||
def _get_current_token_for_account(cls, account_id: str, token_type: str) -> Optional[str]:
|
||||
key = cls._get_account_token_key(account_id, token_type)
|
||||
current_token = redis_client.get(key)
|
||||
return current_token
|
||||
|
||||
@classmethod
|
||||
def _set_current_token_for_account(cls, account_id: str, token: str, token_type: str, expiry_hours: int):
|
||||
key = cls._get_account_token_key(account_id, token_type)
|
||||
redis_client.setex(key, expiry_hours * 60 * 60, token)
|
||||
|
||||
@classmethod
|
||||
def _get_account_token_key(cls, account_id: str, token_type: str) -> str:
|
||||
return f'{token_type}:account:{account_id}'
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, prefix: str, max_attempts: int, time_window: int):
|
||||
self.prefix = prefix
|
||||
self.max_attempts = max_attempts
|
||||
self.time_window = time_window
|
||||
|
||||
def _get_key(self, email: str) -> str:
|
||||
return f"{self.prefix}:{email}"
|
||||
|
||||
def is_rate_limited(self, email: str) -> bool:
|
||||
key = self._get_key(email)
|
||||
current_time = int(time.time())
|
||||
window_start_time = current_time - self.time_window
|
||||
|
||||
redis_client.zremrangebyscore(key, '-inf', window_start_time)
|
||||
attempts = redis_client.zcard(key)
|
||||
|
||||
if attempts and int(attempts) >= self.max_attempts:
|
||||
return True
|
||||
return False
|
||||
|
||||
def increment_rate_limit(self, email: str):
|
||||
key = self._get_key(email)
|
||||
current_time = int(time.time())
|
||||
|
||||
redis_client.zadd(key, {current_time: current_time})
|
||||
redis_client.expire(key, self.time_window * 2)
|
||||
|
|
|
@ -13,6 +13,7 @@ from werkzeug.exceptions import Unauthorized
|
|||
from constants.languages import language_timezone_mapping, languages
|
||||
from events.tenant_event import tenant_was_created
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.helper import RateLimiter, TokenManager
|
||||
from libs.passport import PassportService
|
||||
from libs.password import compare_password, hash_password, valid_password
|
||||
from libs.rsa import generate_key_pair
|
||||
|
@ -29,14 +30,22 @@ from services.errors.account import (
|
|||
LinkAccountIntegrateError,
|
||||
MemberNotInTenantError,
|
||||
NoPermissionError,
|
||||
RateLimitExceededError,
|
||||
RoleAlreadyAssignedError,
|
||||
TenantNotFound,
|
||||
)
|
||||
from tasks.mail_invite_member_task import send_invite_member_mail_task
|
||||
from tasks.mail_reset_password_task import send_reset_password_mail_task
|
||||
|
||||
|
||||
class AccountService:
|
||||
|
||||
reset_password_rate_limiter = RateLimiter(
|
||||
prefix="reset_password_rate_limit",
|
||||
max_attempts=5,
|
||||
time_window=60 * 60
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def load_user(user_id: str) -> Account:
|
||||
account = Account.query.filter_by(id=user_id).first()
|
||||
|
@ -222,9 +231,33 @@ class AccountService:
|
|||
return None
|
||||
return AccountService.load_user(account_id)
|
||||
|
||||
@classmethod
|
||||
def send_reset_password_email(cls, account):
|
||||
if cls.reset_password_rate_limiter.is_rate_limited(account.email):
|
||||
raise RateLimitExceededError(f"Rate limit exceeded for email: {account.email}. Please try again later.")
|
||||
|
||||
token = TokenManager.generate_token(account, 'reset_password')
|
||||
send_reset_password_mail_task.delay(
|
||||
language=account.interface_language,
|
||||
to=account.email,
|
||||
token=token
|
||||
)
|
||||
cls.reset_password_rate_limiter.increment_rate_limit(account.email)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def revoke_reset_password_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, 'reset_password')
|
||||
|
||||
@classmethod
|
||||
def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]:
|
||||
return TokenManager.get_token_data(token, 'reset_password')
|
||||
|
||||
|
||||
def _get_login_cache_key(*, account_id: str, token: str):
|
||||
return f"account_login:{account_id}:{token}"
|
||||
|
||||
|
||||
class TenantService:
|
||||
|
||||
@staticmethod
|
||||
|
|
|
@ -51,3 +51,8 @@ class MemberNotInTenantError(BaseServiceError):
|
|||
|
||||
class RoleAlreadyAssignedError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class RateLimitExceededError(BaseServiceError):
|
||||
pass
|
||||
|
||||
|
|
|
@ -39,16 +39,15 @@ def send_invite_member_mail_task(language: str, to: str, token: str, inviter_nam
|
|||
mail.send(to=to, subject="立即加入 Dify 工作空间", html=html_content)
|
||||
else:
|
||||
html_content = render_template('invite_member_mail_template_en-US.html',
|
||||
to=to,
|
||||
inviter_name=inviter_name,
|
||||
workspace_name=workspace_name,
|
||||
url=url)
|
||||
to=to,
|
||||
inviter_name=inviter_name,
|
||||
workspace_name=workspace_name,
|
||||
url=url)
|
||||
mail.send(to=to, subject="Join Dify Workspace Now", html=html_content)
|
||||
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style('Send invite member mail to {} succeeded: latency: {}'.format(to, end_at - start_at),
|
||||
fg='green'))
|
||||
except Exception:
|
||||
logging.exception("Send invite member mail to {} failed".format(to))
|
||||
logging.exception("Send invite member mail to {} failed".format(to))
|
44
api/tasks/mail_reset_password_task.py
Normal file
44
api/tasks/mail_reset_password_task.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
import logging
|
||||
import time
|
||||
|
||||
import click
|
||||
from celery import shared_task
|
||||
from flask import current_app, render_template
|
||||
|
||||
from extensions.ext_mail import mail
|
||||
|
||||
|
||||
@shared_task(queue='mail')
|
||||
def send_reset_password_mail_task(language: str, to: str, token: str):
|
||||
"""
|
||||
Async Send reset password mail
|
||||
:param language: Language in which the email should be sent (e.g., 'en', 'zh')
|
||||
:param to: Recipient email address
|
||||
:param token: Reset password token to be included in the email
|
||||
"""
|
||||
if not mail.is_inited():
|
||||
return
|
||||
|
||||
logging.info(click.style('Start password reset mail to {}'.format(to), fg='green'))
|
||||
start_at = time.perf_counter()
|
||||
|
||||
# send reset password mail using different languages
|
||||
try:
|
||||
url = f'{current_app.config.get("CONSOLE_WEB_URL")}/forgot-password?token={token}'
|
||||
if language == 'zh-Hans':
|
||||
html_content = render_template('reset_password_mail_template_zh-CN.html',
|
||||
to=to,
|
||||
url=url)
|
||||
mail.send(to=to, subject="重置您的 Dify 密码", html=html_content)
|
||||
else:
|
||||
html_content = render_template('reset_password_mail_template_en-US.html',
|
||||
to=to,
|
||||
url=url)
|
||||
mail.send(to=to, subject="Reset Your Dify Password", html=html_content)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logging.info(
|
||||
click.style('Send password reset mail to {} succeeded: latency: {}'.format(to, end_at - start_at),
|
||||
fg='green'))
|
||||
except Exception:
|
||||
logging.exception("Send password reset mail to {} failed".format(to))
|
72
api/templates/reset_password_mail_template_en-US.html
Normal file
72
api/templates/reset_password_mail_template_en-US.html
Normal file
|
@ -0,0 +1,72 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #374151;
|
||||
background-color: #E5E7EB;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
background-color: #F3F4F6;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #2970FF;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #265DD4;
|
||||
}
|
||||
.footer {
|
||||
font-size: 0.9em;
|
||||
color: #777777;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo">
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Dear {{ to }},</p>
|
||||
<p>We have received a request to reset your password. If you initiated this request, please click the button below to reset your password:</p>
|
||||
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Reset Password</a></p>
|
||||
<p>If you did not request a password reset, please ignore this email and your account will remain secure.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Best regards,</p>
|
||||
<p>Dify Team</p>
|
||||
<p>Please do not reply directly to this email; it is automatically sent by the system.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
72
api/templates/reset_password_mail_template_zh-CN.html
Normal file
72
api/templates/reset_password_mail_template_zh-CN.html
Normal file
|
@ -0,0 +1,72 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
line-height: 16pt;
|
||||
color: #374151;
|
||||
background-color: #E5E7EB;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
background-color: #F3F4F6;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header img {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #2970FF;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #265DD4;
|
||||
}
|
||||
.footer {
|
||||
font-size: 0.9em;
|
||||
color: #777777;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="https://cloud.dify.ai/logo/logo-site.png" alt="Dify Logo">
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>尊敬的 {{ to }},</p>
|
||||
<p>我们收到了您关于重置密码的请求。如果是您本人操作,请点击以下按钮重置您的密码:</p>
|
||||
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">重置密码</a></p>
|
||||
<p>如果您没有请求重置密码,请忽略此邮件,您的账户信息将保持安全。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此致,</p>
|
||||
<p>Dify 团队</p>
|
||||
<p>请不要直接回复此电子邮件;由系统自动发送。</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -427,6 +427,10 @@ INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=1000
|
|||
# Default: 72.
|
||||
INVITE_EXPIRY_HOURS=72
|
||||
|
||||
# Reset password token valid time (hours),
|
||||
# Default: 24.
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_HOURS=24
|
||||
|
||||
# The sandbox service endpoint.
|
||||
CODE_EXECUTION_ENDPOINT=http://sandbox:8194
|
||||
CODE_MAX_NUMBER=9223372036854775807
|
||||
|
|
|
@ -145,6 +145,7 @@ x-shared-env: &shared-api-worker-env
|
|||
RESEND_API_URL: https://api.resend.com
|
||||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-1000}
|
||||
INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_HOURS: ${RESET_PASSWORD_TOKEN_EXPIRY_HOURS:-24}
|
||||
CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194}
|
||||
CODE_EXECUTION_API_KEY: ${SANDBOX_API_KEY:-dify-sandbox}
|
||||
CODE_MAX_NUMBER: ${CODE_MAX_NUMBER:-9223372036854775807}
|
||||
|
|
178
web/app/forgot-password/ChangePasswordForm.tsx
Normal file
178
web/app/forgot-password/ChangePasswordForm.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
'use client'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import cn from 'classnames'
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { changePasswordWithToken, verifyForgotPasswordToken } from '@/service/common'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
|
||||
|
||||
const ChangePasswordForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const verifyTokenParams = {
|
||||
url: '/forgot-password/validity',
|
||||
body: { token },
|
||||
}
|
||||
const { data: verifyTokenRes, mutate: revalidateToken } = useSWR(verifyTokenParams, verifyForgotPasswordToken, {
|
||||
revalidateOnFocus: false,
|
||||
})
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
|
||||
const showErrorMessage = useCallback((message: string) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const valid = useCallback(() => {
|
||||
if (!password.trim()) {
|
||||
showErrorMessage(t('login.error.passwordEmpty'))
|
||||
return false
|
||||
}
|
||||
if (!validPassword.test(password)) {
|
||||
showErrorMessage(t('login.error.passwordInvalid'))
|
||||
return false
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
showErrorMessage(t('common.account.notEqual'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [password, confirmPassword, showErrorMessage, t])
|
||||
|
||||
const handleChangePassword = useCallback(async () => {
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
if (!valid())
|
||||
return
|
||||
try {
|
||||
await changePasswordWithToken({
|
||||
url: '/forgot-password/resets',
|
||||
body: {
|
||||
token,
|
||||
new_password: password,
|
||||
password_confirm: confirmPassword,
|
||||
},
|
||||
})
|
||||
setShowSuccess(true)
|
||||
}
|
||||
catch {
|
||||
await revalidateToken()
|
||||
}
|
||||
}, [password, revalidateToken, token, valid])
|
||||
|
||||
return (
|
||||
<div className={
|
||||
cn(
|
||||
'flex flex-col items-center w-full grow justify-center',
|
||||
'px-6',
|
||||
'md:px-[108px]',
|
||||
)
|
||||
}>
|
||||
{!verifyTokenRes && <Loading />}
|
||||
{verifyTokenRes && !verifyTokenRes.is_valid && (
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
<div className="w-full mx-auto">
|
||||
<div className="mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold">🤷♂️</div>
|
||||
<h2 className="text-[32px] font-bold text-gray-900">{t('login.invalid')}</h2>
|
||||
</div>
|
||||
<div className="w-full mx-auto mt-6">
|
||||
<Button variant='primary' className='w-full !text-sm'>
|
||||
<a href="https://dify.ai">{t('login.explore')}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{verifyTokenRes && verifyTokenRes.is_valid && !showSuccess && (
|
||||
<div className='flex flex-col md:w-[400px]'>
|
||||
<div className="w-full mx-auto">
|
||||
<h2 className="text-[32px] font-bold text-gray-900">
|
||||
{t('login.changePassword')}
|
||||
</h2>
|
||||
<p className='mt-1 text-sm text-gray-600'>
|
||||
{t('login.changePasswordTip')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full mx-auto mt-6">
|
||||
<div className="bg-white">
|
||||
{/* Password */}
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('common.account.newPassword')}
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="password"
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={t('login.passwordPlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
|
||||
</div>
|
||||
{/* Confirm Password */}
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="confirmPassword" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('common.account.confirmPassword')}
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type='password'
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('login.confirmPasswordPlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='w-full !text-sm'
|
||||
onClick={handleChangePassword}
|
||||
>
|
||||
{t('common.operation.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{verifyTokenRes && verifyTokenRes.is_valid && showSuccess && (
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
<div className="w-full mx-auto">
|
||||
<div className="mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold">
|
||||
<CheckCircleIcon className='w-10 h-10 text-[#039855]' />
|
||||
</div>
|
||||
<h2 className="text-[32px] font-bold text-gray-900">
|
||||
{t('login.passwordChangedTip')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="w-full mx-auto mt-6">
|
||||
<Button variant='primary' className='w-full !text-sm'>
|
||||
<a href="/signin">{t('login.passwordChanged')}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChangePasswordForm
|
122
web/app/forgot-password/ForgotPasswordForm.tsx
Normal file
122
web/app/forgot-password/ForgotPasswordForm.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
'use client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import Loading from '../components/base/loading'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
import {
|
||||
fetchInitValidateStatus,
|
||||
fetchSetupStatus,
|
||||
sendForgotPasswordEmail,
|
||||
} from '@/service/common'
|
||||
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: 'login.error.emailInValid' })
|
||||
.email('login.error.emailInValid'),
|
||||
})
|
||||
|
||||
type AccountFormValues = z.infer<typeof accountFormSchema>
|
||||
|
||||
const ForgotPasswordForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isEmailSent, setIsEmailSent] = useState(false)
|
||||
const { register, trigger, getValues, formState: { errors } } = useForm<AccountFormValues>({
|
||||
resolver: zodResolver(accountFormSchema),
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
|
||||
const handleSendResetPasswordEmail = async (email: string) => {
|
||||
try {
|
||||
const res = await sendForgotPasswordEmail({
|
||||
url: '/forgot-password',
|
||||
body: { email },
|
||||
})
|
||||
if (res.result === 'success')
|
||||
setIsEmailSent(true)
|
||||
|
||||
else console.error('Email verification failed')
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Request failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendResetPasswordClick = async () => {
|
||||
if (isEmailSent) {
|
||||
router.push('/signin')
|
||||
}
|
||||
else {
|
||||
const isValid = await trigger('email')
|
||||
if (isValid) {
|
||||
const email = getValues('email')
|
||||
await handleSendResetPasswordEmail(email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSetupStatus().then((res: SetupStatusResponse) => {
|
||||
fetchInitValidateStatus().then((res: InitValidateStatusResponse) => {
|
||||
if (res.status === 'not_started')
|
||||
window.location.href = '/init'
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
loading
|
||||
? <Loading/>
|
||||
: <>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="text-[32px] font-bold text-gray-900">
|
||||
{isEmailSent ? t('login.resetLinkSent') : t('login.forgotPassword')}
|
||||
</h2>
|
||||
<p className='mt-1 text-sm text-gray-600'>
|
||||
{isEmailSent ? t('login.checkEmailForResetLink') : t('login.forgotPasswordDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grow mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white ">
|
||||
<form>
|
||||
{!isEmailSent && (
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="email"
|
||||
className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
{t('login.email')}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
{...register('email')}
|
||||
placeholder={t('login.emailPlaceholder') || ''}
|
||||
className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'}
|
||||
/>
|
||||
{errors.email && <span className='text-red-400 text-sm'>{t(`${errors.email?.message}`)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button variant='primary' className='w-full' onClick={handleSendResetPasswordClick}>
|
||||
{isEmailSent ? t('login.backToSignIn') : t('login.sendResetLink')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForgotPasswordForm
|
38
web/app/forgot-password/page.tsx
Normal file
38
web/app/forgot-password/page.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
'use client'
|
||||
import React from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import Header from '../signin/_header'
|
||||
import style from '../signin/page.module.css'
|
||||
import ForgotPasswordForm from './ForgotPasswordForm'
|
||||
import ChangePasswordForm from '@/app/forgot-password/ChangePasswordForm'
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
style.background,
|
||||
'flex w-full min-h-screen',
|
||||
'p-4 lg:p-8',
|
||||
'gap-x-20',
|
||||
'justify-center lg:justify-start',
|
||||
)}>
|
||||
<div className={
|
||||
classNames(
|
||||
'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
|
||||
'md:w-[608px] space-between',
|
||||
)
|
||||
}>
|
||||
<Header />
|
||||
{token ? <ChangePasswordForm /> : <ForgotPasswordForm />}
|
||||
<div className='px-8 py-6 text-sm font-normal text-gray-500'>
|
||||
© {new Date().getFullYear()} Dify, Inc. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForgotPassword
|
|
@ -224,21 +224,9 @@ const NormalForm = () => {
|
|||
<div className='mb-4'>
|
||||
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
<span>{t('login.password')}</span>
|
||||
{/* <Tooltip
|
||||
selector='forget-password'
|
||||
htmlContent={
|
||||
<div>
|
||||
<div className='font-medium'>{t('login.forget')}</div>
|
||||
<div className='font-medium text-gray-500'>
|
||||
<code>
|
||||
sudo rm -rf /
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className='cursor-pointer text-primary-600'>{t('login.forget')}</span>
|
||||
</Tooltip> */}
|
||||
<Link href='/forgot-password' className='text-primary-600'>
|
||||
{t('login.forget')}
|
||||
</Link>
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
|
|
|
@ -34,6 +34,19 @@ const translation = {
|
|||
donthave: 'Hast du nicht?',
|
||||
invalidInvitationCode: 'Ungültiger Einladungscode',
|
||||
accountAlreadyInited: 'Konto bereits initialisiert',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
resetLinkSent: 'Link zum Zurücksetzen gesendet',
|
||||
sendResetLink: 'Link zum Zurücksetzen senden',
|
||||
backToSignIn: 'Zurück zur Anmeldung',
|
||||
forgotPasswordDesc: 'Bitte geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen. Wir senden Ihnen eine E-Mail mit Anweisungen zum Zurücksetzen Ihres Passworts.',
|
||||
checkEmailForResetLink: 'Bitte überprüfen Sie Ihre E-Mails auf einen Link zum Zurücksetzen Ihres Passworts. Wenn er nicht innerhalb weniger Minuten erscheint, überprüfen Sie bitte Ihren Spam-Ordner.',
|
||||
passwordChanged: 'Jetzt anmelden',
|
||||
changePassword: 'Passwort ändern',
|
||||
changePasswordTip: 'Bitte geben Sie ein neues Passwort für Ihr Konto ein',
|
||||
invalidToken: 'Ungültiges oder abgelaufenes Token',
|
||||
confirmPassword: 'Passwort bestätigen',
|
||||
confirmPasswordPlaceholder: 'Bestätigen Sie Ihr neues Passwort',
|
||||
passwordChangedTip: 'Ihr Passwort wurde erfolgreich geändert',
|
||||
error: {
|
||||
emailEmpty: 'E-Mail-Adresse wird benötigt',
|
||||
emailInValid: 'Bitte gib eine gültige E-Mail-Adresse ein',
|
||||
|
|
|
@ -35,6 +35,19 @@ const translation = {
|
|||
donthave: 'Don\'t have?',
|
||||
invalidInvitationCode: 'Invalid invitation code',
|
||||
accountAlreadyInited: 'Account already initialized',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
resetLinkSent: 'Reset link sent',
|
||||
sendResetLink: 'Send reset link',
|
||||
backToSignIn: 'Return to sign in',
|
||||
forgotPasswordDesc: 'Please enter your email address to reset your password. We will send you an email with instructions on how to reset your password.',
|
||||
checkEmailForResetLink: 'Please check your email for a link to reset your password. If it doesn\'t appear within a few minutes, make sure to check your spam folder.',
|
||||
passwordChanged: 'Sign in now',
|
||||
changePassword: 'Change Password',
|
||||
changePasswordTip: 'Please enter a new password for your account',
|
||||
invalidToken: 'Invalid or expired token',
|
||||
confirmPassword: 'Confirm Password',
|
||||
confirmPasswordPlaceholder: 'Confirm your new password',
|
||||
passwordChangedTip: 'Your password has been successfully changed',
|
||||
error: {
|
||||
emailEmpty: 'Email address is required',
|
||||
emailInValid: 'Please enter a valid email address',
|
||||
|
|
|
@ -34,6 +34,19 @@ const translation = {
|
|||
donthave: 'Vous n\'avez pas ?',
|
||||
invalidInvitationCode: 'Code d\'invitation invalide',
|
||||
accountAlreadyInited: 'Compte déjà initialisé',
|
||||
forgotPassword: 'Mot de passe oublié?',
|
||||
resetLinkSent: 'Lien de réinitialisation envoyé',
|
||||
sendResetLink: 'Envoyer le lien de réinitialisation',
|
||||
backToSignIn: 'Retour à la connexion',
|
||||
forgotPasswordDesc: 'Veuillez entrer votre adresse e-mail pour réinitialiser votre mot de passe. Nous vous enverrons un e-mail avec des instructions sur la réinitialisation de votre mot de passe.',
|
||||
checkEmailForResetLink: 'Veuillez vérifier votre e-mail pour un lien de réinitialisation de votre mot de passe. S\'il n\'apparaît pas dans quelques minutes, assurez-vous de vérifier votre dossier de spam.',
|
||||
passwordChanged: 'Connectez-vous maintenant',
|
||||
changePassword: 'Changer le mot de passe',
|
||||
changePasswordTip: 'Veuillez entrer un nouveau mot de passe pour votre compte',
|
||||
invalidToken: 'Token invalide ou expiré',
|
||||
confirmPassword: 'Confirmez le mot de passe',
|
||||
confirmPasswordPlaceholder: 'Confirmez votre nouveau mot de passe',
|
||||
passwordChangedTip: 'Votre mot de passe a été changé avec succès',
|
||||
error: {
|
||||
emailEmpty: 'Une adresse e-mail est requise',
|
||||
emailInValid: 'Veuillez entrer une adresse email valide',
|
||||
|
|
|
@ -39,6 +39,19 @@ const translation = {
|
|||
donthave: 'नहीं है?',
|
||||
invalidInvitationCode: 'अवैध निमंत्रण कोड',
|
||||
accountAlreadyInited: 'खाता पहले से प्रारंभ किया गया है',
|
||||
forgotPassword: 'क्या आपने अपना पासवर्ड भूल गए हैं?',
|
||||
resetLinkSent: 'रीसेट लिंक भेजी गई',
|
||||
sendResetLink: 'रीसेट लिंक भेजें',
|
||||
backToSignIn: 'साइन इन पर वापस जाएं',
|
||||
forgotPasswordDesc: 'कृपया अपना ईमेल पता दर्ज करें ताकि हम आपको अपना पासवर्ड रीसेट करने के निर्देशों के साथ एक ईमेल भेज सकें।',
|
||||
checkEmailForResetLink: 'कृपया अपना पासवर्ड रीसेट करने के लिए लिंक के लिए अपना ईमेल चेक करें। अगर यह कुछ मिनटों के भीतर नहीं आता है, तो कृपया अपना स्पैम फोल्डर भी चेक करें।',
|
||||
passwordChanged: 'अब साइन इन करें',
|
||||
changePassword: 'पासवर्ड बदलें',
|
||||
changePasswordTip: 'कृपया अपने खाते के लिए नया पासवर्ड दर्ज करें',
|
||||
invalidToken: 'अमान्य या समाप्त टोकन',
|
||||
confirmPassword: 'पासवर्ड की पुष्टि करें',
|
||||
confirmPasswordPlaceholder: 'अपना नया पासवर्ड पुष्टि करें',
|
||||
passwordChangedTip: 'आपका पासवर्ड सफलतापूर्वक बदल दिया गया है',
|
||||
error: {
|
||||
emailEmpty: 'ईमेल पता आवश्यक है',
|
||||
emailInValid: 'कृपया एक मान्य ईमेल पता दर्ज करें',
|
||||
|
|
|
@ -34,6 +34,19 @@ const translation = {
|
|||
donthave: 'お持ちでない場合',
|
||||
invalidInvitationCode: '無効な招待コード',
|
||||
accountAlreadyInited: 'アカウントは既に初期化されています',
|
||||
forgotPassword: 'パスワードを忘れましたか?',
|
||||
resetLinkSent: 'リセットリンクが送信されました',
|
||||
sendResetLink: 'リセットリンクを送信',
|
||||
backToSignIn: 'サインインに戻る',
|
||||
forgotPasswordDesc: 'パスワードをリセットするためにメールアドレスを入力してください。パスワードのリセット方法に関する指示が記載されたメールを送信します。',
|
||||
checkEmailForResetLink: 'パスワードリセットリンクを確認するためにメールを確認してください。数分以内に表示されない場合は、スパムフォルダーを確認してください。',
|
||||
passwordChanged: '今すぐサインイン',
|
||||
changePassword: 'パスワードを変更する',
|
||||
changePasswordTip: 'アカウントの新しいパスワードを入力してください',
|
||||
invalidToken: '無効または期限切れのトークン',
|
||||
confirmPassword: 'パスワードを確認',
|
||||
confirmPasswordPlaceholder: '新しいパスワードを確認してください',
|
||||
passwordChangedTip: 'パスワードが正常に変更されました',
|
||||
error: {
|
||||
emailEmpty: 'メールアドレスは必須です',
|
||||
emailInValid: '有効なメールアドレスを入力してください',
|
||||
|
|
|
@ -34,6 +34,19 @@ const translation = {
|
|||
donthave: '계정이 없으신가요?',
|
||||
invalidInvitationCode: '유효하지 않은 초대 코드입니다.',
|
||||
accountAlreadyInited: '계정은 이미 초기화되었습니다.',
|
||||
forgotPassword: '비밀번호를 잊으셨나요?',
|
||||
resetLinkSent: '재설정 링크가 전송되었습니다',
|
||||
sendResetLink: '재설정 링크 보내기',
|
||||
backToSignIn: '로그인으로 돌아가기',
|
||||
forgotPasswordDesc: '비밀번호를 재설정하려면 이메일 주소를 입력하세요. 비밀번호 재설정 방법에 대한 이메일을 보내드리겠습니다.',
|
||||
checkEmailForResetLink: '비밀번호 재설정 링크를 확인하려면 이메일을 확인하세요. 몇 분 내에 나타나지 않으면 스팸 폴더를 확인하세요.',
|
||||
passwordChanged: '지금 로그인',
|
||||
changePassword: '비밀번호 변경',
|
||||
changePasswordTip: '계정의 새 비밀번호를 입력하세요',
|
||||
invalidToken: '유효하지 않거나 만료된 토큰',
|
||||
confirmPassword: '비밀번호 확인',
|
||||
confirmPasswordPlaceholder: '새 비밀번호를 확인하세요',
|
||||
passwordChangedTip: '비밀번호가 성공적으로 변경되었습니다',
|
||||
error: {
|
||||
emailEmpty: '이메일 주소를 입력하세요.',
|
||||
emailInValid: '유효한 이메일 주소를 입력하세요.',
|
||||
|
|
|
@ -39,6 +39,19 @@ const translation = {
|
|||
donthave: 'Nie masz?',
|
||||
invalidInvitationCode: 'Niewłaściwy kod zaproszenia',
|
||||
accountAlreadyInited: 'Konto już zainicjowane',
|
||||
forgotPassword: 'Zapomniałeś hasła?',
|
||||
resetLinkSent: 'Link resetujący został wysłany',
|
||||
sendResetLink: 'Wyślij link resetujący',
|
||||
backToSignIn: 'Powrót do logowania',
|
||||
forgotPasswordDesc: 'Proszę podać swój adres e-mail, aby zresetować hasło. Wyślemy Ci e-mail z instrukcjami, jak zresetować hasło.',
|
||||
checkEmailForResetLink: 'Proszę sprawdzić swój e-mail w poszukiwaniu linku do resetowania hasła. Jeśli nie pojawi się w ciągu kilku minut, sprawdź folder spam.',
|
||||
passwordChanged: 'Zaloguj się teraz',
|
||||
changePassword: 'Zmień hasło',
|
||||
changePasswordTip: 'Wprowadź nowe hasło do swojego konta',
|
||||
invalidToken: 'Nieprawidłowy lub wygasły token',
|
||||
confirmPassword: 'Potwierdź hasło',
|
||||
confirmPasswordPlaceholder: 'Potwierdź nowe hasło',
|
||||
passwordChangedTip: 'Twoje hasło zostało pomyślnie zmienione',
|
||||
error: {
|
||||
emailEmpty: 'Adres e-mail jest wymagany',
|
||||
emailInValid: 'Proszę wpisać prawidłowy adres e-mail',
|
||||
|
|
|
@ -34,6 +34,19 @@ const translation = {
|
|||
donthave: 'Não tem?',
|
||||
invalidInvitationCode: 'Código de convite inválido',
|
||||
accountAlreadyInited: 'Conta já iniciada',
|
||||
forgotPassword: 'Esqueceu sua senha?',
|
||||
resetLinkSent: 'Link de redefinição enviado',
|
||||
sendResetLink: 'Enviar link de redefinição',
|
||||
backToSignIn: 'Voltar para login',
|
||||
forgotPasswordDesc: 'Por favor, insira seu endereço de e-mail para redefinir sua senha. Enviaremos um e-mail com instruções sobre como redefinir sua senha.',
|
||||
checkEmailForResetLink: 'Verifique seu e-mail para um link para redefinir sua senha. Se não aparecer dentro de alguns minutos, verifique sua pasta de spam.',
|
||||
passwordChanged: 'Entre agora',
|
||||
changePassword: 'Mudar a senha',
|
||||
changePasswordTip: 'Por favor, insira uma nova senha para sua conta',
|
||||
invalidToken: 'Token inválido ou expirado',
|
||||
confirmPassword: 'Confirme a Senha',
|
||||
confirmPasswordPlaceholder: 'Confirme sua nova senha',
|
||||
passwordChangedTip: 'Sua senha foi alterada com sucesso',
|
||||
error: {
|
||||
emailEmpty: 'O endereço de e-mail é obrigatório',
|
||||
emailInValid: 'Digite um endereço de e-mail válido',
|
||||
|
|
|
@ -35,6 +35,19 @@ const translation = {
|
|||
donthave: 'Nu ai?',
|
||||
invalidInvitationCode: 'Cod de invitație invalid',
|
||||
accountAlreadyInited: 'Contul este deja inițializat',
|
||||
forgotPassword: 'Ați uitat parola?',
|
||||
resetLinkSent: 'Link de resetare trimis',
|
||||
sendResetLink: 'Trimiteți linkul de resetare',
|
||||
backToSignIn: 'Înapoi la autentificare',
|
||||
forgotPasswordDesc: 'Vă rugăm să introduceți adresa de e-mail pentru a reseta parola. Vă vom trimite un e-mail cu instrucțiuni despre cum să resetați parola.',
|
||||
checkEmailForResetLink: 'Vă rugăm să verificați e-mailul pentru un link de resetare a parolei. Dacă nu apare în câteva minute, verificați folderul de spam.',
|
||||
passwordChanged: 'Conectează-te acum',
|
||||
changePassword: 'Schimbă parola',
|
||||
changePasswordTip: 'Vă rugăm să introduceți o nouă parolă pentru contul dvs',
|
||||
invalidToken: 'Token invalid sau expirat',
|
||||
confirmPassword: 'Confirmă parola',
|
||||
confirmPasswordPlaceholder: 'Confirmați noua parolă',
|
||||
passwordChangedTip: 'Parola dvs. a fost schimbată cu succes',
|
||||
error: {
|
||||
emailEmpty: 'Adresa de email este obligatorie',
|
||||
emailInValid: 'Te rugăm să introduci o adresă de email validă',
|
||||
|
|
|
@ -34,6 +34,19 @@ const translation = {
|
|||
donthave: 'Не маєте?',
|
||||
invalidInvitationCode: 'Недійсний код запрошення',
|
||||
accountAlreadyInited: 'Обліковий запис уже ініціалізовано',
|
||||
forgotPassword: 'Забули пароль?',
|
||||
resetLinkSent: 'Посилання для скидання надіслано',
|
||||
sendResetLink: 'Надіслати посилання для скидання',
|
||||
backToSignIn: 'Повернутися до входу',
|
||||
forgotPasswordDesc: 'Будь ласка, введіть свою електронну адресу, щоб скинути пароль. Ми надішлемо вам електронного листа з інструкціями щодо скидання пароля.',
|
||||
checkEmailForResetLink: 'Будь ласка, перевірте свою електронну пошту на наявність посилання для скидання пароля. Якщо протягом кількох хвилин не з’явиться, перевірте папку зі спамом.',
|
||||
passwordChanged: 'Увійдіть зараз',
|
||||
changePassword: 'Змінити пароль',
|
||||
changePasswordTip: 'Будь ласка, введіть новий пароль для свого облікового запису',
|
||||
invalidToken: 'Недійсний або прострочений токен',
|
||||
confirmPassword: 'Підтвердити пароль',
|
||||
confirmPasswordPlaceholder: 'Підтвердьте новий пароль',
|
||||
passwordChangedTip: 'Ваш пароль було успішно змінено',
|
||||
error: {
|
||||
emailEmpty: 'Адреса електронної пошти обов\'язкова',
|
||||
emailInValid: 'Введіть дійсну адресу електронної пошти',
|
||||
|
|
|
@ -34,6 +34,19 @@ const translation = {
|
|||
donthave: 'Chưa có?',
|
||||
invalidInvitationCode: 'Mã mời không hợp lệ',
|
||||
accountAlreadyInited: 'Tài khoản đã được khởi tạo',
|
||||
forgotPassword: 'Quên mật khẩu?',
|
||||
resetLinkSent: 'Đã gửi liên kết đặt lại mật khẩu',
|
||||
sendResetLink: 'Gửi liên kết đặt lại mật khẩu',
|
||||
backToSignIn: 'Quay lại đăng nhập',
|
||||
forgotPasswordDesc: 'Vui lòng nhập địa chỉ email của bạn để đặt lại mật khẩu. Chúng tôi sẽ gửi cho bạn một email với hướng dẫn về cách đặt lại mật khẩu.',
|
||||
checkEmailForResetLink: 'Vui lòng kiểm tra email của bạn để nhận liên kết đặt lại mật khẩu. Nếu không thấy trong vài phút, hãy kiểm tra thư mục spam.',
|
||||
passwordChanged: 'Đăng nhập ngay',
|
||||
changePassword: 'Đổi mật khẩu',
|
||||
changePasswordTip: 'Vui lòng nhập mật khẩu mới cho tài khoản của bạn',
|
||||
invalidToken: 'Mã thông báo không hợp lệ hoặc đã hết hạn',
|
||||
confirmPassword: 'Xác nhận mật khẩu',
|
||||
confirmPasswordPlaceholder: 'Xác nhận mật khẩu mới của bạn',
|
||||
passwordChangedTip: 'Mật khẩu của bạn đã được thay đổi thành công',
|
||||
error: {
|
||||
emailEmpty: 'Địa chỉ Email là bắt buộc',
|
||||
emailInValid: 'Vui lòng nhập một địa chỉ email hợp lệ',
|
||||
|
|
|
@ -34,6 +34,19 @@ const translation = {
|
|||
donthave: '还没有邀请码?',
|
||||
invalidInvitationCode: '无效的邀请码',
|
||||
accountAlreadyInited: '账户已经初始化',
|
||||
forgotPassword: '忘记密码?',
|
||||
resetLinkSent: '重置链接已发送',
|
||||
sendResetLink: '发送重置链接',
|
||||
backToSignIn: '返回登录',
|
||||
forgotPasswordDesc: '请输入您的电子邮件地址以重置密码。我们将向您发送一封电子邮件,包含如何重置密码的说明。',
|
||||
checkEmailForResetLink: '请检查您的电子邮件以获取重置密码的链接。如果几分钟内没有收到,请检查您的垃圾邮件文件夹。',
|
||||
passwordChanged: '立即登录',
|
||||
changePassword: '更改密码',
|
||||
changePasswordTip: '请输入您的新密码',
|
||||
invalidToken: '无效或已过期的令牌',
|
||||
confirmPassword: '确认密码',
|
||||
confirmPasswordPlaceholder: '确认您的新密码',
|
||||
passwordChangedTip: '您的密码已成功更改',
|
||||
error: {
|
||||
emailEmpty: '邮箱不能为空',
|
||||
emailInValid: '请输入有效的邮箱地址',
|
||||
|
|
|
@ -34,6 +34,19 @@ const translation = {
|
|||
donthave: '還沒有邀請碼?',
|
||||
invalidInvitationCode: '無效的邀請碼',
|
||||
accountAlreadyInited: '賬戶已經初始化',
|
||||
forgotPassword: '忘記密碼?',
|
||||
resetLinkSent: '重設連結已發送',
|
||||
sendResetLink: '發送重設連結',
|
||||
backToSignIn: '返回登錄',
|
||||
forgotPasswordDesc: '請輸入您的電子郵件地址以重設密碼。我們將向您發送一封電子郵件,說明如何重設密碼。',
|
||||
checkEmailForResetLink: '請檢查您的電子郵件以獲取重設密碼的連結。如果幾分鐘內沒有收到,請檢查您的垃圾郵件文件夾。',
|
||||
passwordChanged: '立即登入',
|
||||
changePassword: '更改密碼',
|
||||
changePasswordTip: '請輸入您的新密碼',
|
||||
invalidToken: '無效或已過期的令牌',
|
||||
confirmPassword: '確認密碼',
|
||||
confirmPasswordPlaceholder: '確認您的新密碼',
|
||||
passwordChangedTip: '您的密碼已成功更改',
|
||||
error: {
|
||||
emailEmpty: '郵箱不能為空',
|
||||
emailInValid: '請輸入有效的郵箱地址',
|
||||
|
|
|
@ -298,3 +298,13 @@ export const enableModel = (url: string, body: { model: string; model_type: Mode
|
|||
|
||||
export const disableModel = (url: string, body: { model: string; model_type: ModelTypeEnum }) =>
|
||||
patch<CommonResponse>(url, { body })
|
||||
|
||||
export const sendForgotPasswordEmail: Fetcher<CommonResponse, { url: string; body: { email: string } }> = ({ url, body }) =>
|
||||
post<CommonResponse>(url, { body })
|
||||
|
||||
export const verifyForgotPasswordToken: Fetcher<CommonResponse & { is_valid: boolean; email: string }, { url: string; body: { token: string } }> = ({ url, body }) => {
|
||||
return post(url, { body }) as Promise<CommonResponse & { is_valid: boolean; email: string }>
|
||||
}
|
||||
|
||||
export const changePasswordWithToken: Fetcher<CommonResponse, { url: string; body: { token: string; new_password: string; password_confirm: string } }> = ({ url, body }) =>
|
||||
post<CommonResponse>(url, { body })
|
||||
|
|
Loading…
Reference in New Issue
Block a user