From 00b4cc3cd44439d933a38d0ca28fde9b5c350a05 Mon Sep 17 00:00:00 2001 From: xielong Date: Fri, 5 Jul 2024 13:38:51 +0800 Subject: [PATCH] feat: implement forgot password feature (#5534) --- .gitignore | 2 + api/configs/feature/__init__.py | 4 + api/controllers/console/__init__.py | 2 +- api/controllers/console/auth/error.py | 25 +++ .../console/auth/forgot_password.py | 107 +++++++++++ api/controllers/console/workspace/account.py | 2 + api/libs/helper.py | 107 ++++++++++- api/services/account_service.py | 33 ++++ api/services/errors/account.py | 5 + api/tasks/mail_invite_member_task.py | 11 +- api/tasks/mail_reset_password_task.py | 44 +++++ .../reset_password_mail_template_en-US.html | 72 +++++++ .../reset_password_mail_template_zh-CN.html | 72 +++++++ docker/.env.example | 4 + docker/docker-compose.yaml | 1 + .../forgot-password/ChangePasswordForm.tsx | 178 ++++++++++++++++++ .../forgot-password/ForgotPasswordForm.tsx | 122 ++++++++++++ web/app/forgot-password/page.tsx | 38 ++++ web/app/signin/normalForm.tsx | 18 +- web/i18n/de-DE/login.ts | 13 ++ web/i18n/en-US/login.ts | 13 ++ web/i18n/fr-FR/login.ts | 13 ++ web/i18n/hi-IN/login.ts | 13 ++ web/i18n/ja-JP/login.ts | 13 ++ web/i18n/ko-KR/login.ts | 13 ++ web/i18n/pl-PL/login.ts | 13 ++ web/i18n/pt-BR/login.ts | 13 ++ web/i18n/ro-RO/login.ts | 13 ++ web/i18n/uk-UA/login.ts | 13 ++ web/i18n/vi-VN/login.ts | 13 ++ web/i18n/zh-Hans/login.ts | 13 ++ web/i18n/zh-Hant/login.ts | 13 ++ web/service/common.ts | 10 + 33 files changed, 1000 insertions(+), 26 deletions(-) create mode 100644 api/controllers/console/auth/forgot_password.py create mode 100644 api/tasks/mail_reset_password_task.py create mode 100644 api/templates/reset_password_mail_template_en-US.html create mode 100644 api/templates/reset_password_mail_template_zh-CN.html create mode 100644 web/app/forgot-password/ChangePasswordForm.tsx create mode 100644 web/app/forgot-password/ForgotPasswordForm.tsx create mode 100644 web/app/forgot-password/page.tsx diff --git a/.gitignore b/.gitignore index 0c7e5c712f..2f44cf7934 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,5 @@ sdks/python-client/dify_client.egg-info .vscode/* !.vscode/launch.json pyrightconfig.json + +.idea/ diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 20371c7828..1b202fad73 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -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): """ diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 8c67fef95f..bef40bea7e 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -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 diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index c55ff8707d..53dab3298f 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -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 + diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py new file mode 100644 index 0000000000..d78be770ab --- /dev/null +++ b/api/controllers/console/auth/forgot_password.py @@ -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') diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 198409bba7..0b5c84c2a3 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -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') diff --git a/api/libs/helper.py b/api/libs/helper.py index ebabb2ea47..335c6688f4 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -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) diff --git a/api/services/account_service.py b/api/services/account_service.py index 3112ad80a8..3fd2b5c627 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -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 diff --git a/api/services/errors/account.py b/api/services/errors/account.py index 14612eed75..ddc2dbdea8 100644 --- a/api/services/errors/account.py +++ b/api/services/errors/account.py @@ -51,3 +51,8 @@ class MemberNotInTenantError(BaseServiceError): class RoleAlreadyAssignedError(BaseServiceError): pass + + +class RateLimitExceededError(BaseServiceError): + pass + diff --git a/api/tasks/mail_invite_member_task.py b/api/tasks/mail_invite_member_task.py index 3341f5f4b8..1f40c05077 100644 --- a/api/tasks/mail_invite_member_task.py +++ b/api/tasks/mail_invite_member_task.py @@ -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)) \ No newline at end of file diff --git a/api/tasks/mail_reset_password_task.py b/api/tasks/mail_reset_password_task.py new file mode 100644 index 0000000000..0e64c6f163 --- /dev/null +++ b/api/tasks/mail_reset_password_task.py @@ -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)) diff --git a/api/templates/reset_password_mail_template_en-US.html b/api/templates/reset_password_mail_template_en-US.html new file mode 100644 index 0000000000..ffc558ab66 --- /dev/null +++ b/api/templates/reset_password_mail_template_en-US.html @@ -0,0 +1,72 @@ + + + + + + + +
+
+ Dify Logo +
+
+

Dear {{ to }},

+

We have received a request to reset your password. If you initiated this request, please click the button below to reset your password:

+

Reset Password

+

If you did not request a password reset, please ignore this email and your account will remain secure.

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

尊敬的 {{ to }},

+

我们收到了您关于重置密码的请求。如果是您本人操作,请点击以下按钮重置您的密码:

+

重置密码

+

如果您没有请求重置密码,请忽略此邮件,您的账户信息将保持安全。

+
+ +
+ + diff --git a/docker/.env.example b/docker/.env.example index 008d5cd4cc..fd2cbe2b9d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 788206d22f..d947532301 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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} diff --git a/web/app/forgot-password/ChangePasswordForm.tsx b/web/app/forgot-password/ChangePasswordForm.tsx new file mode 100644 index 0000000000..d878660416 --- /dev/null +++ b/web/app/forgot-password/ChangePasswordForm.tsx @@ -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 ( +
+ {!verifyTokenRes && } + {verifyTokenRes && !verifyTokenRes.is_valid && ( +
+
+
🤷‍♂️
+

{t('login.invalid')}

+
+
+ +
+
+ )} + {verifyTokenRes && verifyTokenRes.is_valid && !showSuccess && ( +
+
+

+ {t('login.changePassword')} +

+

+ {t('login.changePasswordTip')} +

+
+ +
+
+ {/* Password */} +
+ +
+ 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'} + /> +
+
{t('login.error.passwordInvalid')}
+
+ {/* Confirm Password */} +
+ +
+ 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'} + /> +
+
+
+ +
+
+
+
+ )} + {verifyTokenRes && verifyTokenRes.is_valid && showSuccess && ( +
+
+
+ +
+

+ {t('login.passwordChangedTip')} +

+
+
+ +
+
+ )} +
+ ) +} + +export default ChangePasswordForm diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx new file mode 100644 index 0000000000..6fd69a3638 --- /dev/null +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -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 + +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({ + 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 + ? + : <> +
+

+ {isEmailSent ? t('login.resetLinkSent') : t('login.forgotPassword')} +

+

+ {isEmailSent ? t('login.checkEmailForResetLink') : t('login.forgotPasswordDesc')} +

+
+
+
+
+ {!isEmailSent && ( +
+ +
+ + {errors.email && {t(`${errors.email?.message}`)}} +
+
+ )} +
+ +
+
+
+
+ + ) +} + +export default ForgotPasswordForm diff --git a/web/app/forgot-password/page.tsx b/web/app/forgot-password/page.tsx new file mode 100644 index 0000000000..fa44d1a20c --- /dev/null +++ b/web/app/forgot-password/page.tsx @@ -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 ( +
+
+
+ {token ? : } +
+ © {new Date().getFullYear()} Dify, Inc. All rights reserved. +
+
+
+ ) +} + +export default ForgotPassword diff --git a/web/app/signin/normalForm.tsx b/web/app/signin/normalForm.tsx index f23a04e4e4..40912c6e1f 100644 --- a/web/app/signin/normalForm.tsx +++ b/web/app/signin/normalForm.tsx @@ -224,21 +224,9 @@ const NormalForm = () => {
- } - > - {t('login.forget')} - */} + + {t('login.forget')} +
patch(url, { body }) + +export const sendForgotPasswordEmail: Fetcher = ({ url, body }) => + post(url, { body }) + +export const verifyForgotPasswordToken: Fetcher = ({ url, body }) => { + return post(url, { body }) as Promise +} + +export const changePasswordWithToken: Fetcher = ({ url, body }) => + post(url, { body })