feat: implement forgot password feature (#5534)

This commit is contained in:
xielong 2024-07-05 13:38:51 +08:00 committed by GitHub
parent f546db5437
commit 00b4cc3cd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1000 additions and 26 deletions

2
.gitignore vendored
View File

@ -174,3 +174,5 @@ sdks/python-client/dify_client.egg-info
.vscode/*
!.vscode/launch.json
pyrightconfig.json
.idea/

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -51,3 +51,8 @@ class MemberNotInTenantError(BaseServiceError):
class RoleAlreadyAssignedError(BaseServiceError):
pass
class RateLimitExceededError(BaseServiceError):
pass

View File

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

View 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))

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

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

View File

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

View File

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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: 'कृपया एक मान्य ईमेल पता दर्ज करें',

View File

@ -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: '有効なメールアドレスを入力してください',

View File

@ -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: '유효한 이메일 주소를 입력하세요.',

View File

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

View File

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

View File

@ -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ă',

View File

@ -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: 'Введіть дійсну адресу електронної пошти',

View File

@ -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ệ',

View File

@ -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: '请输入有效的邮箱地址',

View File

@ -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: '請輸入有效的郵箱地址',

View File

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