From 233727ed6a5a1ba44dd04ec57b9e9a513da06440 Mon Sep 17 00:00:00 2001 From: bitloi Date: Mon, 19 Jan 2026 14:35:10 +0100 Subject: [PATCH 01/14] feat(#884): Add password reset functionality - Add password reset token model and Pydantic schemas (server) - Add forgot-password, reset-password, verify-reset-token API endpoints (server + backend) - Add database migration for password_reset_token table - Add ForgotPassword.tsx and ResetPassword.tsx frontend pages - Add 'Forgot Password?' link to Login page - Add routes for /forgot-password and /reset-password - Add i18n translations for en-us and zh-Hans Note: Email sending integration pending - requires email service configuration. Currently returns token in API response for development/testing. Closes #884 --- .../controller/password_reset_controller.py | 127 ++++++++ backend/app/router.py | 7 +- ..._19_1200-add_password_reset_token_table.py | 44 +++ .../user/password_reset_controller.py | 191 ++++++++++++ server/app/model/user/password_reset.py | 29 ++ src/i18n/locales/en-us/layout.json | 28 +- src/i18n/locales/zh-Hans/layout.json | 28 +- src/pages/ForgotPassword.tsx | 160 ++++++++++ src/pages/Login.tsx | 9 + src/pages/ResetPassword.tsx | 279 ++++++++++++++++++ src/routers/index.tsx | 4 + 11 files changed, 903 insertions(+), 3 deletions(-) create mode 100644 backend/app/controller/password_reset_controller.py create mode 100644 server/alembic/versions/2026_01_19_1200-add_password_reset_token_table.py create mode 100644 server/app/controller/user/password_reset_controller.py create mode 100644 server/app/model/user/password_reset.py create mode 100644 src/pages/ForgotPassword.tsx create mode 100644 src/pages/ResetPassword.tsx diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py new file mode 100644 index 000000000..d5e7da2cf --- /dev/null +++ b/backend/app/controller/password_reset_controller.py @@ -0,0 +1,127 @@ +""" +Password Reset Controller +Handles forgot password and reset password functionality. +""" +import secrets +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from utils import traceroot_wrapper as traceroot + +logger = traceroot.get_logger("password_reset_controller") + +router = APIRouter() + +# In-memory token storage for development (in production, use database) +password_reset_tokens = {} + +TOKEN_EXPIRATION_HOURS = 24 + + +class ForgotPasswordRequest(BaseModel): + email: str + + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str + confirm_password: str + + +def generate_reset_token() -> str: + """Generate a secure random token for password reset.""" + return secrets.token_urlsafe(32) + + +@router.post("/forgot-password", name="request password reset") +async def forgot_password(data: ForgotPasswordRequest): + """ + Request a password reset link. + For security, always returns success even if email doesn't exist. + In development, returns the token directly for testing. + """ + email = data.email + logger.info(f"Password reset requested for email: {email}") + + # Generate token + token = generate_reset_token() + expires_at = datetime.now() + timedelta(hours=TOKEN_EXPIRATION_HOURS) + + # Store token (in production, this would be in database) + password_reset_tokens[token] = { + "email": email, + "expires_at": expires_at, + "used": False + } + + # In development, return the token for testing + return { + "status": "success", + "message": "If an account with that email exists, a password reset link has been sent.", + "reset_token": token, # Only for development + "expires_at": expires_at.isoformat(), + } + + +@router.get("/verify-reset-token/{token}", name="verify reset token") +async def verify_reset_token(token: str): + """ + Verify if a password reset token is valid. + """ + token_data = password_reset_tokens.get(token) + + if not token_data: + return {"valid": False, "message": "Invalid or expired reset token."} + + if token_data["used"]: + return {"valid": False, "message": "This reset token has already been used."} + + if datetime.now() > token_data["expires_at"]: + return {"valid": False, "message": "This reset token has expired."} + + return {"valid": True, "message": "Token is valid."} + + +@router.post("/reset-password", name="reset password") +async def reset_password(data: ResetPasswordRequest): + """ + Reset password using a valid token. + """ + token = data.token + new_password = data.new_password + confirm_password = data.confirm_password + + # Validate passwords match + if new_password != confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match.") + + # Validate password strength (basic check) + if len(new_password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.") + + has_letter = any(c.isalpha() for c in new_password) + has_number = any(c.isdigit() for c in new_password) + if not (has_letter and has_number): + raise HTTPException(status_code=400, detail="Password must contain both letters and numbers.") + + # Verify token + token_data = password_reset_tokens.get(token) + + if not token_data: + raise HTTPException(status_code=400, detail="Invalid or expired reset token.") + + if token_data["used"]: + raise HTTPException(status_code=400, detail="This reset token has already been used.") + + if datetime.now() > token_data["expires_at"]: + raise HTTPException(status_code=400, detail="This reset token has expired.") + + # Mark token as used + token_data["used"] = True + + logger.info(f"Password reset successful for email: {token_data['email']}") + + return { + "status": "success", + "message": "Your password has been reset successfully." + } diff --git a/backend/app/router.py b/backend/app/router.py index 3fc6cb257..1a7730470 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -3,7 +3,7 @@ All routers are explicitly registered here for better visibility and maintainability. """ from fastapi import FastAPI -from app.controller import chat_controller, model_controller, task_controller, tool_controller, health_controller +from app.controller import chat_controller, model_controller, task_controller, tool_controller, health_controller, password_reset_controller from utils import traceroot_wrapper as traceroot logger = traceroot.get_logger("router") @@ -48,6 +48,11 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: "tags": ["tool"], "description": "Tool installation and management" }, + { + "router": password_reset_controller.router, + "tags": ["auth"], + "description": "Password reset functionality" + }, ] for config in routers_config: diff --git a/server/alembic/versions/2026_01_19_1200-add_password_reset_token_table.py b/server/alembic/versions/2026_01_19_1200-add_password_reset_token_table.py new file mode 100644 index 000000000..0a12b9acb --- /dev/null +++ b/server/alembic/versions/2026_01_19_1200-add_password_reset_token_table.py @@ -0,0 +1,44 @@ +"""add_password_reset_token_table + +Revision ID: add_password_reset_token +Revises: add_timestamp_to_chat_step +Create Date: 2026-01-19 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "add_password_reset_token" +down_revision: Union[str, None] = "add_timestamp_to_chat_step" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create password_reset_token table.""" + op.create_table( + "password_reset_token", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(255), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("used", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=True), + sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_password_reset_token_user_id", "password_reset_token", ["user_id"]) + op.create_index("ix_password_reset_token_token", "password_reset_token", ["token"], unique=True) + + +def downgrade() -> None: + """Drop password_reset_token table.""" + op.drop_index("ix_password_reset_token_token", table_name="password_reset_token") + op.drop_index("ix_password_reset_token_user_id", table_name="password_reset_token") + op.drop_table("password_reset_token") diff --git a/server/app/controller/user/password_reset_controller.py b/server/app/controller/user/password_reset_controller.py new file mode 100644 index 000000000..e60ee2166 --- /dev/null +++ b/server/app/controller/user/password_reset_controller.py @@ -0,0 +1,191 @@ +import secrets +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends +from sqlmodel import Session, col + +from app.component import code +from app.component.database import session +from app.component.encrypt import password_hash +from app.exception.exception import UserException +from app.model.user.password_reset import ( + ForgotPasswordRequest, + PasswordResetToken, + ResetPasswordRequest, +) +from app.model.user.user import User +from fastapi_babel import _ +from utils import traceroot_wrapper as traceroot + +logger = traceroot.get_logger("server_password_reset_controller") + +router = APIRouter(tags=["Password Reset"]) + +# Token expiration time in hours +TOKEN_EXPIRATION_HOURS = 24 + + +def generate_reset_token() -> str: + """Generate a secure random token for password reset.""" + return secrets.token_urlsafe(32) + + +@router.post("/forgot-password", name="request password reset") +@traceroot.trace() +async def forgot_password( + data: ForgotPasswordRequest, + session: Session = Depends(session), +): + """ + Request a password reset. Generates a reset token for the user. + + For security reasons, always returns success even if email doesn't exist. + In production, this would send an email with the reset link. + """ + email = data.email + user = User.by(User.email == email, col(User.deleted_at).is_(None), s=session).one_or_none() + + if user: + # Invalidate any existing unused tokens for this user + existing_tokens = PasswordResetToken.by( + PasswordResetToken.user_id == user.id, + PasswordResetToken.used == False, # noqa: E712 + col(PasswordResetToken.deleted_at).is_(None), + s=session + ).all() + + for token in existing_tokens: + token.used = True + token.save(session) + + # Generate new token + token = generate_reset_token() + expires_at = datetime.now() + timedelta(hours=TOKEN_EXPIRATION_HOURS) + + reset_token = PasswordResetToken( + user_id=user.id, + token=token, + expires_at=expires_at, + ) + reset_token.save(session) + + logger.info( + "Password reset token generated", + extra={"user_id": user.id, "email": email, "expires_at": str(expires_at)} + ) + + # TODO: In production, send email with reset link + # For now, return the token in response (development only) + # In production, remove token from response and send via email + return { + "status": "success", + "message": "If an account with that email exists, a password reset link has been sent.", + # Development only - remove in production + "reset_token": token, + "expires_at": expires_at.isoformat(), + } + else: + logger.warning( + "Password reset requested for non-existent email", + extra={"email": email} + ) + + # Always return success for security (don't reveal if email exists) + return { + "status": "success", + "message": "If an account with that email exists, a password reset link has been sent.", + } + + +@router.post("/reset-password", name="reset password with token") +@traceroot.trace() +async def reset_password( + data: ResetPasswordRequest, + session: Session = Depends(session), +): + """ + Reset password using a valid reset token. + """ + # Validate passwords match + if data.new_password != data.confirm_password: + logger.warning("Password reset failed: passwords do not match") + raise UserException(code.error, _("Passwords do not match")) + + # Validate password strength + if len(data.new_password) < 8: + raise UserException(code.error, _("Password must be at least 8 characters long")) + + if not any(c.isdigit() for c in data.new_password) or not any(c.isalpha() for c in data.new_password): + raise UserException(code.error, _("Password must contain both letters and numbers")) + + # Find the token + reset_token = PasswordResetToken.by( + PasswordResetToken.token == data.token, + col(PasswordResetToken.deleted_at).is_(None), + s=session + ).one_or_none() + + if not reset_token: + logger.warning("Password reset failed: invalid token") + raise UserException(code.error, _("Invalid or expired reset token")) + + if not reset_token.is_valid(): + logger.warning( + "Password reset failed: token expired or used", + extra={"token_id": reset_token.id, "used": reset_token.used} + ) + raise UserException(code.error, _("Invalid or expired reset token")) + + # Get the user + user = session.get(User, reset_token.user_id) + if not user: + logger.error( + "Password reset failed: user not found", + extra={"user_id": reset_token.user_id} + ) + raise UserException(code.error, _("User not found")) + + # Update password + user.password = password_hash(data.new_password) + user.save(session) + + # Mark token as used + reset_token.used = True + reset_token.save(session) + + logger.info( + "Password reset successful", + extra={"user_id": user.id, "email": user.email} + ) + + return { + "status": "success", + "message": "Password has been reset successfully. You can now log in with your new password.", + } + + +@router.get("/verify-reset-token/{token}", name="verify reset token") +@traceroot.trace() +async def verify_reset_token( + token: str, + session: Session = Depends(session), +): + """ + Verify if a reset token is valid. + Used by frontend to check token before showing reset form. + """ + reset_token = PasswordResetToken.by( + PasswordResetToken.token == token, + col(PasswordResetToken.deleted_at).is_(None), + s=session + ).one_or_none() + + if not reset_token or not reset_token.is_valid(): + return { + "valid": False, + "message": "Invalid or expired reset token", + } + + return { + "valid": True, + "message": "Token is valid", + } diff --git a/server/app/model/user/password_reset.py b/server/app/model/user/password_reset.py new file mode 100644 index 000000000..bdde18428 --- /dev/null +++ b/server/app/model/user/password_reset.py @@ -0,0 +1,29 @@ +from datetime import datetime +from sqlmodel import Field +from app.model.abstract.model import AbstractModel, DefaultTimes +from pydantic import BaseModel, EmailStr + + +class PasswordResetToken(AbstractModel, DefaultTimes, table=True): + """Model for storing password reset tokens.""" + id: int = Field(default=None, primary_key=True) + user_id: int = Field(index=True, foreign_key="user.id") + token: str = Field(unique=True, max_length=255, index=True) + expires_at: datetime = Field() + used: bool = Field(default=False) + + def is_valid(self) -> bool: + """Check if the token is still valid (not expired and not used).""" + return not self.used and datetime.now() < self.expires_at + + +class ForgotPasswordRequest(BaseModel): + """Request model for forgot password endpoint.""" + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + """Request model for reset password endpoint.""" + token: str + new_password: str + confirm_password: str diff --git a/src/i18n/locales/en-us/layout.json b/src/i18n/locales/en-us/layout.json index 860bcafef..9c948861f 100644 --- a/src/i18n/locales/en-us/layout.json +++ b/src/i18n/locales/en-us/layout.json @@ -168,5 +168,31 @@ "days-ago": "days ago", "delete-project": "Delete Project", "delete-project-confirmation": "Are you sure you want to delete this project and all its tasks? This action cannot be undone.", - "please-select-model": "Please select a model in Settings > Models to continue." + "please-select-model": "Please select a model in Settings > Models to continue.", + "forgot-password": "Forgot Password?", + "forgot-password-description": "Enter your email address and we'll send you a link to reset your password.", + "forgot-password-failed": "Failed to send reset link. Please try again.", + "send-reset-link": "Send Reset Link", + "sending": "Sending...", + "check-your-email": "Check Your Email", + "password-reset-email-sent": "If an account with that email exists, we've sent you a link to reset your password. Please check your inbox.", + "back-to-login": "Back to Login", + "reset-password": "Reset Password", + "reset-password-description": "Enter your new password below.", + "reset-password-failed": "Failed to reset password. Please try again.", + "new-password": "New Password", + "enter-new-password": "Enter new password", + "confirm-password": "Confirm Password", + "confirm-new-password": "Confirm new password", + "please-confirm-password": "Please confirm your password", + "passwords-do-not-match": "Passwords do not match", + "password-must-contain-letters-and-numbers": "Password must contain both letters and numbers", + "resetting": "Resetting...", + "verifying": "Verifying", + "invalid-reset-link": "Invalid Reset Link", + "reset-link-expired-or-invalid": "This password reset link is invalid or has expired. Please request a new one.", + "request-new-link": "Request New Link", + "password-reset-success": "Password Reset Successfully", + "password-reset-success-description": "Your password has been reset. You can now log in with your new password.", + "go-to-login": "Go to Login" } diff --git a/src/i18n/locales/zh-Hans/layout.json b/src/i18n/locales/zh-Hans/layout.json index 405b635fe..53489e065 100644 --- a/src/i18n/locales/zh-Hans/layout.json +++ b/src/i18n/locales/zh-Hans/layout.json @@ -168,5 +168,31 @@ "days-ago": "天前", "delete-project": "删除项目", "delete-project-confirmation": "您确定要删除此项目及其所有任务吗?此操作无法撤销。", - "please-select-model": "请在设置 > 模型中选择一个模型以继续。" + "please-select-model": "请在设置 > 模型中选择一个模型以继续。", + "forgot-password": "忘记密码?", + "forgot-password-description": "输入您的电子邮件地址,我们将向您发送重置密码的链接。", + "forgot-password-failed": "发送重置链接失败,请重试。", + "send-reset-link": "发送重置链接", + "sending": "发送中...", + "check-your-email": "检查您的邮箱", + "password-reset-email-sent": "如果该邮箱存在账户,我们已向您发送了重置密码的链接。请检查您的收件箱。", + "back-to-login": "返回登录", + "reset-password": "重置密码", + "reset-password-description": "请在下方输入您的新密码。", + "reset-password-failed": "重置密码失败,请重试。", + "new-password": "新密码", + "enter-new-password": "输入新密码", + "confirm-password": "确认密码", + "confirm-new-password": "确认新密码", + "please-confirm-password": "请确认您的密码", + "passwords-do-not-match": "两次输入的密码不一致", + "password-must-contain-letters-and-numbers": "密码必须包含字母和数字", + "resetting": "重置中...", + "verifying": "验证中", + "invalid-reset-link": "无效的重置链接", + "reset-link-expired-or-invalid": "此密码重置链接无效或已过期。请重新申请。", + "request-new-link": "申请新链接", + "password-reset-success": "密码重置成功", + "password-reset-success-description": "您的密码已重置。现在可以使用新密码登录。", + "go-to-login": "前往登录" } diff --git a/src/pages/ForgotPassword.tsx b/src/pages/ForgotPassword.tsx new file mode 100644 index 000000000..411bce4e8 --- /dev/null +++ b/src/pages/ForgotPassword.tsx @@ -0,0 +1,160 @@ +import { useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import loginGif from '@/assets/login.gif'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { proxyFetchPost } from '@/api/http'; +import { useTranslation } from 'react-i18next'; + +export default function ForgotPassword() { + const navigate = useNavigate(); + const { t } = useTranslation(); + const [email, setEmail] = useState(''); + const [emailError, setEmailError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [generalError, setGeneralError] = useState(''); + + const validateEmail = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const validateForm = () => { + if (!email) { + setEmailError(t('layout.please-enter-email-address')); + return false; + } + if (!validateEmail(email)) { + setEmailError(t('layout.please-enter-a-valid-email-address')); + return false; + } + setEmailError(''); + return true; + }; + + const handleInputChange = (value: string) => { + setEmail(value); + if (emailError) { + setEmailError(''); + } + if (generalError) { + setGeneralError(''); + } + }; + + const handleSubmit = async () => { + if (!validateForm()) { + return; + } + + setGeneralError(''); + setIsLoading(true); + try { + const data = await proxyFetchPost('/api/forgot-password', { + email: email, + }); + + if (data.code && data.code !== 0) { + setGeneralError(data.text || t('layout.forgot-password-failed')); + return; + } + + setIsSuccess(true); + } catch (error: any) { + console.error('Forgot password request failed:', error); + setGeneralError(t('layout.forgot-password-failed')); + } finally { + setIsLoading(false); + } + }; + + if (isSuccess) { + return ( +
+
+ +
+
+
+
+ {t('layout.check-your-email')} +
+

+ {t('layout.password-reset-email-sent')} +

+ +
+
+
+ ); + } + + return ( +
+
+ +
+
+
+
+
+ {t('layout.forgot-password')} +
+ +
+

+ {t('layout.forgot-password-description')} +

+
+ {generalError && ( +

+ {generalError} +

+ )} +
+ handleInputChange(e.target.value)} + state={emailError ? 'error' : undefined} + note={emailError} + onEnter={handleSubmit} + /> +
+
+ +
+
+
+ ); +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index a6bfb57c0..7f6e5297c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -433,6 +433,15 @@ export default function Login() { /> +
+ +
+ + + + + ); + } + + if (isSuccess) { + return ( +
+
+ +
+
+
+
+ {t('layout.password-reset-success')} +
+

+ {t('layout.password-reset-success-description')} +

+ +
+
+
+ ); + } + + return ( +
+
+ +
+
+
+
+
+ {t('layout.reset-password')} +
+
+

+ {t('layout.reset-password-description')} +

+
+ {generalError && ( +

+ {generalError} +

+ )} +
+ handleInputChange('newPassword', e.target.value)} + state={errors.newPassword ? 'error' : undefined} + note={errors.newPassword} + backIcon={} + onBackIconClick={() => setHidePassword(!hidePassword)} + /> + + handleInputChange('confirmPassword', e.target.value)} + state={errors.confirmPassword ? 'error' : undefined} + note={errors.confirmPassword} + backIcon={} + onBackIconClick={() => setHideConfirmPassword(!hideConfirmPassword)} + onEnter={handleSubmit} + /> +
+
+ +
+
+
+ ); +} diff --git a/src/routers/index.tsx b/src/routers/index.tsx index 223d0a52e..aae001102 100644 --- a/src/routers/index.tsx +++ b/src/routers/index.tsx @@ -6,6 +6,8 @@ import Layout from "@/components/Layout"; // Lazy load page components const Login = lazy(() => import("@/pages/Login")); const Signup = lazy(() => import("@/pages/SignUp")); +const ForgotPassword = lazy(() => import("@/pages/ForgotPassword")); +const ResetPassword = lazy(() => import("@/pages/ResetPassword")); const Home = lazy(() => import("@/pages/Home")); const History = lazy(() => import("@/pages/History")); const NotFound = lazy(() => import("@/pages/NotFound")); @@ -54,6 +56,8 @@ const AppRoutes = () => ( } /> } /> + } /> + } /> }> }> } /> From 58a8af6fc1a55da59ce67932f82754274040646d Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 21 Jan 2026 14:17:44 +0100 Subject: [PATCH 02/14] refactor(#884): Update password reset for local vs cloud deployment - Login.tsx: Forgot Password button now redirects to Eigent website when VITE_USE_LOCAL_PROXY=false, navigates to /forgot-password when true - ForgotPassword.tsx: Redesigned for local deployment - direct password reset with email + new password fields (no email verification needed) - Added /reset-password-direct endpoint to both server and Electron backends for direct password update in Docker database Behavior: - Full Local Deployment (VITE_USE_LOCAL_PROXY=true): Direct password reset without email verification, updates password in local Docker database - End Users (VITE_USE_LOCAL_PROXY=false): Redirects to https://www.eigent.ai/forgot-password --- .../controller/password_reset_controller.py | 41 ++++++ .../user/password_reset_controller.py | 56 +++++++++ src/pages/ForgotPassword.tsx | 117 ++++++++++++++---- src/pages/Login.tsx | 12 +- 4 files changed, 201 insertions(+), 25 deletions(-) diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py index d5e7da2cf..8821c71d9 100644 --- a/backend/app/controller/password_reset_controller.py +++ b/backend/app/controller/password_reset_controller.py @@ -125,3 +125,44 @@ async def reset_password(data: ResetPasswordRequest): "status": "success", "message": "Your password has been reset successfully." } + + +class DirectResetPasswordRequest(BaseModel): + """Request model for direct password reset (local deployment only).""" + email: str + new_password: str + confirm_password: str + + +@router.post("/reset-password-direct", name="reset password directly") +async def reset_password_direct(data: DirectResetPasswordRequest): + """ + Reset password directly without token verification. + This endpoint is for Full Local Deployment only where email verification is not needed. + Note: This is a simplified implementation for the Electron backend. + The actual password update happens in the server backend for Docker deployments. + """ + # Validate passwords match + if data.new_password != data.confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match.") + + # Validate password strength + if len(data.new_password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.") + + has_letter = any(c.isalpha() for c in data.new_password) + has_number = any(c.isdigit() for c in data.new_password) + if not (has_letter and has_number): + raise HTTPException(status_code=400, detail="Password must contain both letters and numbers.") + + logger.info(f"Direct password reset requested for email: {data.email}") + + # Note: In the Electron backend, this endpoint acts as a proxy. + # The actual password update is handled by the server backend for Docker deployments. + # For now, return success - the frontend will call the server backend directly + # when VITE_USE_LOCAL_PROXY=true + + return { + "status": "success", + "message": "Password has been reset successfully. You can now log in with your new password." + } diff --git a/server/app/controller/user/password_reset_controller.py b/server/app/controller/user/password_reset_controller.py index e60ee2166..eaf008ce6 100644 --- a/server/app/controller/user/password_reset_controller.py +++ b/server/app/controller/user/password_reset_controller.py @@ -1,6 +1,7 @@ import secrets from datetime import datetime, timedelta from fastapi import APIRouter, Depends +from pydantic import BaseModel from sqlmodel import Session, col from app.component import code @@ -189,3 +190,58 @@ async def verify_reset_token( "valid": True, "message": "Token is valid", } + + +class DirectResetPasswordRequest(BaseModel): + """Request model for direct password reset (local deployment only).""" + email: str + new_password: str + confirm_password: str + + +@router.post("/reset-password-direct", name="reset password directly") +@traceroot.trace() +async def reset_password_direct( + data: DirectResetPasswordRequest, + session: Session = Depends(session), +): + """ + Reset password directly without token verification. + This endpoint is for Full Local Deployment only where email verification is not needed. + The password is updated directly in the local Docker database. + """ + # Validate passwords match + if data.new_password != data.confirm_password: + logger.warning("Direct password reset failed: passwords do not match") + raise UserException(code.error, _("Passwords do not match")) + + # Validate password strength + if len(data.new_password) < 8: + raise UserException(code.error, _("Password must be at least 8 characters long")) + + if not any(c.isdigit() for c in data.new_password) or not any(c.isalpha() for c in data.new_password): + raise UserException(code.error, _("Password must contain both letters and numbers")) + + # Find the user by email + user = User.by(User.email == data.email, col(User.deleted_at).is_(None), s=session).one_or_none() + + if not user: + logger.warning( + "Direct password reset failed: user not found", + extra={"email": data.email} + ) + raise UserException(code.error, _("User with this email not found")) + + # Update password + user.password = password_hash(data.new_password) + user.save(session) + + logger.info( + "Direct password reset successful", + extra={"user_id": user.id, "email": user.email} + ) + + return { + "status": "success", + "message": "Password has been reset successfully. You can now log in with your new password.", + } diff --git a/src/pages/ForgotPassword.tsx b/src/pages/ForgotPassword.tsx index 411bce4e8..4597905e5 100644 --- a/src/pages/ForgotPassword.tsx +++ b/src/pages/ForgotPassword.tsx @@ -5,42 +5,69 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { proxyFetchPost } from '@/api/http'; import { useTranslation } from 'react-i18next'; +import eye from '@/assets/eye.svg'; +import eyeOff from '@/assets/eye-off.svg'; export default function ForgotPassword() { const navigate = useNavigate(); const { t } = useTranslation(); const [email, setEmail] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); const [emailError, setEmailError] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [confirmPasswordError, setConfirmPasswordError] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [generalError, setGeneralError] = useState(''); + const [hidePassword, setHidePassword] = useState(true); + const [hideConfirmPassword, setHideConfirmPassword] = useState(true); const validateEmail = (email: string) => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); }; + const validatePassword = (password: string) => { + const hasLetter = /[a-zA-Z]/.test(password); + const hasNumber = /[0-9]/.test(password); + return password.length >= 8 && hasLetter && hasNumber; + }; + const validateForm = () => { + let isValid = true; + if (!email) { setEmailError(t('layout.please-enter-email-address')); - return false; - } - if (!validateEmail(email)) { + isValid = false; + } else if (!validateEmail(email)) { setEmailError(t('layout.please-enter-a-valid-email-address')); - return false; + isValid = false; + } else { + setEmailError(''); } - setEmailError(''); - return true; - }; - const handleInputChange = (value: string) => { - setEmail(value); - if (emailError) { - setEmailError(''); + if (!newPassword) { + setPasswordError(t('layout.please-enter-password')); + isValid = false; + } else if (!validatePassword(newPassword)) { + setPasswordError(t('layout.password-must-contain-letters-and-numbers')); + isValid = false; + } else { + setPasswordError(''); } - if (generalError) { - setGeneralError(''); + + if (!confirmPassword) { + setConfirmPasswordError(t('layout.please-confirm-password')); + isValid = false; + } else if (newPassword !== confirmPassword) { + setConfirmPasswordError(t('layout.passwords-do-not-match')); + isValid = false; + } else { + setConfirmPasswordError(''); } + + return isValid; }; const handleSubmit = async () => { @@ -51,19 +78,21 @@ export default function ForgotPassword() { setGeneralError(''); setIsLoading(true); try { - const data = await proxyFetchPost('/api/forgot-password', { + const data = await proxyFetchPost('/api/reset-password-direct', { email: email, + new_password: newPassword, + confirm_password: confirmPassword, }); if (data.code && data.code !== 0) { - setGeneralError(data.text || t('layout.forgot-password-failed')); + setGeneralError(data.text || t('layout.reset-password-failed')); return; } setIsSuccess(true); } catch (error: any) { - console.error('Forgot password request failed:', error); - setGeneralError(t('layout.forgot-password-failed')); + console.error('Reset password request failed:', error); + setGeneralError(t('layout.reset-password-failed')); } finally { setIsLoading(false); } @@ -78,10 +107,10 @@ export default function ForgotPassword() {
- {t('layout.check-your-email')} + {t('layout.password-reset-success')}

- {t('layout.password-reset-email-sent')} + {t('layout.password-reset-success-description')}

@@ -106,7 +135,7 @@ export default function ForgotPassword() {
- {t('layout.forgot-password')} + {t('layout.reset-password')}

- {t('layout.forgot-password-description')} + {t('layout.reset-password-description')}

{generalError && ( @@ -134,9 +163,49 @@ export default function ForgotPassword() { placeholder={t('layout.enter-your-email')} required value={email} - onChange={(e) => handleInputChange(e.target.value)} + onChange={(e) => { + setEmail(e.target.value); + if (emailError) setEmailError(''); + if (generalError) setGeneralError(''); + }} state={emailError ? 'error' : undefined} note={emailError} + /> + { + setNewPassword(e.target.value); + if (passwordError) setPasswordError(''); + if (generalError) setGeneralError(''); + }} + state={passwordError ? 'error' : undefined} + note={passwordError} + backIcon={} + onBackIconClick={() => setHidePassword(!hidePassword)} + /> + { + setConfirmPassword(e.target.value); + if (confirmPasswordError) setConfirmPasswordError(''); + if (generalError) setGeneralError(''); + }} + state={confirmPasswordError ? 'error' : undefined} + note={confirmPasswordError} + backIcon={} + onBackIconClick={() => setHideConfirmPassword(!hideConfirmPassword)} onEnter={handleSubmit} />
@@ -150,7 +219,7 @@ export default function ForgotPassword() { disabled={isLoading} > - {isLoading ? t('layout.sending') : t('layout.send-reset-link')} + {isLoading ? t('layout.resetting') : t('layout.reset-password')}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 7f6e5297c..e29c9360a 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -437,7 +437,17 @@ export default function Login() { From e5c2d2eb8e72ad3daf76544f4a6d15c813ca61ad Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 21 Jan 2026 14:33:52 +0100 Subject: [PATCH 03/14] refactor(#884): Use Pydantic validation for password reset models - Create backend/app/model/password_reset.py with Pydantic models: - DirectResetPasswordRequest with field_validator and model_validator - ForgotPasswordRequest with email validation - ResetPasswordRequest with token and password validation - Update server/app/model/user/password_reset.py: - Add Pydantic validators to ResetPasswordRequest - Add DirectResetPasswordRequest model with validators - Update controllers to use models from model folder - Remove manual validation from controllers (now handled by Pydantic) Password validation rules: - Minimum 8 characters - Must contain at least one letter - Must contain at least one number - Passwords must match --- .../controller/password_reset_controller.py | 62 +++-------- backend/app/model/password_reset.py | 102 ++++++++++++++++++ .../user/password_reset_controller.py | 35 +----- server/app/model/user/password_reset.py | 65 ++++++++++- 4 files changed, 181 insertions(+), 83 deletions(-) create mode 100644 backend/app/model/password_reset.py diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py index 8821c71d9..f10d41608 100644 --- a/backend/app/controller/password_reset_controller.py +++ b/backend/app/controller/password_reset_controller.py @@ -4,8 +4,14 @@ """ import secrets from datetime import datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse +from pydantic import ValidationError +from app.model.password_reset import ( + DirectResetPasswordRequest, + ForgotPasswordRequest, + ResetPasswordRequest, +) from utils import traceroot_wrapper as traceroot logger = traceroot.get_logger("password_reset_controller") @@ -18,16 +24,6 @@ TOKEN_EXPIRATION_HOURS = 24 -class ForgotPasswordRequest(BaseModel): - email: str - - -class ResetPasswordRequest(BaseModel): - token: str - new_password: str - confirm_password: str - - def generate_reset_token() -> str: """Generate a secure random token for password reset.""" return secrets.token_urlsafe(32) @@ -86,26 +82,10 @@ async def verify_reset_token(token: str): async def reset_password(data: ResetPasswordRequest): """ Reset password using a valid token. + Password validation is handled by Pydantic model. """ - token = data.token - new_password = data.new_password - confirm_password = data.confirm_password - - # Validate passwords match - if new_password != confirm_password: - raise HTTPException(status_code=400, detail="Passwords do not match.") - - # Validate password strength (basic check) - if len(new_password) < 8: - raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.") - - has_letter = any(c.isalpha() for c in new_password) - has_number = any(c.isdigit() for c in new_password) - if not (has_letter and has_number): - raise HTTPException(status_code=400, detail="Password must contain both letters and numbers.") - # Verify token - token_data = password_reset_tokens.get(token) + token_data = password_reset_tokens.get(data.token) if not token_data: raise HTTPException(status_code=400, detail="Invalid or expired reset token.") @@ -127,34 +107,16 @@ async def reset_password(data: ResetPasswordRequest): } -class DirectResetPasswordRequest(BaseModel): - """Request model for direct password reset (local deployment only).""" - email: str - new_password: str - confirm_password: str - - @router.post("/reset-password-direct", name="reset password directly") async def reset_password_direct(data: DirectResetPasswordRequest): """ Reset password directly without token verification. This endpoint is for Full Local Deployment only where email verification is not needed. + Password validation is handled by Pydantic model. + Note: This is a simplified implementation for the Electron backend. The actual password update happens in the server backend for Docker deployments. """ - # Validate passwords match - if data.new_password != data.confirm_password: - raise HTTPException(status_code=400, detail="Passwords do not match.") - - # Validate password strength - if len(data.new_password) < 8: - raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.") - - has_letter = any(c.isalpha() for c in data.new_password) - has_number = any(c.isdigit() for c in data.new_password) - if not (has_letter and has_number): - raise HTTPException(status_code=400, detail="Password must contain both letters and numbers.") - logger.info(f"Direct password reset requested for email: {data.email}") # Note: In the Electron backend, this endpoint acts as a proxy. diff --git a/backend/app/model/password_reset.py b/backend/app/model/password_reset.py new file mode 100644 index 000000000..cfb8b8223 --- /dev/null +++ b/backend/app/model/password_reset.py @@ -0,0 +1,102 @@ +""" +Password Reset Models +Pydantic models for password reset functionality with validation. +""" +from pydantic import BaseModel, field_validator, model_validator + + +class DirectResetPasswordRequest(BaseModel): + """Request model for direct password reset (local deployment only).""" + email: str + new_password: str + confirm_password: str + + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email format.""" + v = v.strip().lower() + if not v: + raise ValueError("Email is required") + if "@" not in v or "." not in v.split("@")[-1]: + raise ValueError("Invalid email format") + return v + + @field_validator("new_password") + @classmethod + def validate_password_strength(cls, v: str) -> str: + """Validate password meets strength requirements.""" + if len(v) < 8: + raise ValueError("Password must be at least 8 characters long") + + has_letter = any(c.isalpha() for c in v) + has_number = any(c.isdigit() for c in v) + + if not has_letter: + raise ValueError("Password must contain at least one letter") + if not has_number: + raise ValueError("Password must contain at least one number") + + return v + + @model_validator(mode="after") + def validate_passwords_match(self): + """Validate that new_password and confirm_password match.""" + if self.new_password != self.confirm_password: + raise ValueError("Passwords do not match") + return self + + +class ForgotPasswordRequest(BaseModel): + """Request model for forgot password (token-based flow).""" + email: str + + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email format.""" + v = v.strip().lower() + if not v: + raise ValueError("Email is required") + if "@" not in v or "." not in v.split("@")[-1]: + raise ValueError("Invalid email format") + return v + + +class ResetPasswordRequest(BaseModel): + """Request model for reset password with token.""" + token: str + new_password: str + confirm_password: str + + @field_validator("token") + @classmethod + def validate_token(cls, v: str) -> str: + """Validate token is not empty.""" + if not v or not v.strip(): + raise ValueError("Token is required") + return v.strip() + + @field_validator("new_password") + @classmethod + def validate_password_strength(cls, v: str) -> str: + """Validate password meets strength requirements.""" + if len(v) < 8: + raise ValueError("Password must be at least 8 characters long") + + has_letter = any(c.isalpha() for c in v) + has_number = any(c.isdigit() for c in v) + + if not has_letter: + raise ValueError("Password must contain at least one letter") + if not has_number: + raise ValueError("Password must contain at least one number") + + return v + + @model_validator(mode="after") + def validate_passwords_match(self): + """Validate that new_password and confirm_password match.""" + if self.new_password != self.confirm_password: + raise ValueError("Passwords do not match") + return self diff --git a/server/app/controller/user/password_reset_controller.py b/server/app/controller/user/password_reset_controller.py index eaf008ce6..d25e66724 100644 --- a/server/app/controller/user/password_reset_controller.py +++ b/server/app/controller/user/password_reset_controller.py @@ -1,7 +1,6 @@ import secrets from datetime import datetime, timedelta from fastapi import APIRouter, Depends -from pydantic import BaseModel from sqlmodel import Session, col from app.component import code @@ -9,6 +8,7 @@ from app.component.encrypt import password_hash from app.exception.exception import UserException from app.model.user.password_reset import ( + DirectResetPasswordRequest, ForgotPasswordRequest, PasswordResetToken, ResetPasswordRequest, @@ -105,19 +105,8 @@ async def reset_password( ): """ Reset password using a valid reset token. + Password validation is handled by Pydantic model. """ - # Validate passwords match - if data.new_password != data.confirm_password: - logger.warning("Password reset failed: passwords do not match") - raise UserException(code.error, _("Passwords do not match")) - - # Validate password strength - if len(data.new_password) < 8: - raise UserException(code.error, _("Password must be at least 8 characters long")) - - if not any(c.isdigit() for c in data.new_password) or not any(c.isalpha() for c in data.new_password): - raise UserException(code.error, _("Password must contain both letters and numbers")) - # Find the token reset_token = PasswordResetToken.by( PasswordResetToken.token == data.token, @@ -192,13 +181,6 @@ async def verify_reset_token( } -class DirectResetPasswordRequest(BaseModel): - """Request model for direct password reset (local deployment only).""" - email: str - new_password: str - confirm_password: str - - @router.post("/reset-password-direct", name="reset password directly") @traceroot.trace() async def reset_password_direct( @@ -209,19 +191,8 @@ async def reset_password_direct( Reset password directly without token verification. This endpoint is for Full Local Deployment only where email verification is not needed. The password is updated directly in the local Docker database. + Password validation is handled by Pydantic model. """ - # Validate passwords match - if data.new_password != data.confirm_password: - logger.warning("Direct password reset failed: passwords do not match") - raise UserException(code.error, _("Passwords do not match")) - - # Validate password strength - if len(data.new_password) < 8: - raise UserException(code.error, _("Password must be at least 8 characters long")) - - if not any(c.isdigit() for c in data.new_password) or not any(c.isalpha() for c in data.new_password): - raise UserException(code.error, _("Password must contain both letters and numbers")) - # Find the user by email user = User.by(User.email == data.email, col(User.deleted_at).is_(None), s=session).one_or_none() diff --git a/server/app/model/user/password_reset.py b/server/app/model/user/password_reset.py index bdde18428..73b248248 100644 --- a/server/app/model/user/password_reset.py +++ b/server/app/model/user/password_reset.py @@ -1,7 +1,7 @@ from datetime import datetime from sqlmodel import Field from app.model.abstract.model import AbstractModel, DefaultTimes -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, field_validator, model_validator class PasswordResetToken(AbstractModel, DefaultTimes, table=True): @@ -27,3 +27,66 @@ class ResetPasswordRequest(BaseModel): token: str new_password: str confirm_password: str + + @field_validator("token") + @classmethod + def validate_token(cls, v: str) -> str: + """Validate token is not empty.""" + if not v or not v.strip(): + raise ValueError("Token is required") + return v.strip() + + @field_validator("new_password") + @classmethod + def validate_password_strength(cls, v: str) -> str: + """Validate password meets strength requirements.""" + if len(v) < 8: + raise ValueError("Password must be at least 8 characters long") + + has_letter = any(c.isalpha() for c in v) + has_number = any(c.isdigit() for c in v) + + if not has_letter: + raise ValueError("Password must contain at least one letter") + if not has_number: + raise ValueError("Password must contain at least one number") + + return v + + @model_validator(mode="after") + def validate_passwords_match(self): + """Validate that new_password and confirm_password match.""" + if self.new_password != self.confirm_password: + raise ValueError("Passwords do not match") + return self + + +class DirectResetPasswordRequest(BaseModel): + """Request model for direct password reset (local deployment only).""" + email: EmailStr + new_password: str + confirm_password: str + + @field_validator("new_password") + @classmethod + def validate_password_strength(cls, v: str) -> str: + """Validate password meets strength requirements.""" + if len(v) < 8: + raise ValueError("Password must be at least 8 characters long") + + has_letter = any(c.isalpha() for c in v) + has_number = any(c.isdigit() for c in v) + + if not has_letter: + raise ValueError("Password must contain at least one letter") + if not has_number: + raise ValueError("Password must contain at least one number") + + return v + + @model_validator(mode="after") + def validate_passwords_match(self): + """Validate that new_password and confirm_password match.""" + if self.new_password != self.confirm_password: + raise ValueError("Passwords do not match") + return self From 41fe1cd25f954de9a714a9e2e064db1162545822 Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 21 Jan 2026 22:50:46 +0100 Subject: [PATCH 04/14] fix(#884): Remove sensitive email data from log messages Security fix: Remove email addresses from log output to prevent clear-text logging of sensitive information. - backend/app/controller/password_reset_controller.py - server/app/controller/user/password_reset_controller.py --- backend/app/controller/password_reset_controller.py | 4 ++-- server/app/controller/user/password_reset_controller.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py index f10d41608..8908e59ce 100644 --- a/backend/app/controller/password_reset_controller.py +++ b/backend/app/controller/password_reset_controller.py @@ -99,7 +99,7 @@ async def reset_password(data: ResetPasswordRequest): # Mark token as used token_data["used"] = True - logger.info(f"Password reset successful for email: {token_data['email']}") + logger.info("Password reset successful") return { "status": "success", @@ -117,7 +117,7 @@ async def reset_password_direct(data: DirectResetPasswordRequest): Note: This is a simplified implementation for the Electron backend. The actual password update happens in the server backend for Docker deployments. """ - logger.info(f"Direct password reset requested for email: {data.email}") + logger.info("Direct password reset requested") # Note: In the Electron backend, this endpoint acts as a proxy. # The actual password update is handled by the server backend for Docker deployments. diff --git a/server/app/controller/user/password_reset_controller.py b/server/app/controller/user/password_reset_controller.py index d25e66724..9debb639b 100644 --- a/server/app/controller/user/password_reset_controller.py +++ b/server/app/controller/user/password_reset_controller.py @@ -144,7 +144,7 @@ async def reset_password( logger.info( "Password reset successful", - extra={"user_id": user.id, "email": user.email} + extra={"user_id": user.id} ) return { @@ -197,10 +197,7 @@ async def reset_password_direct( user = User.by(User.email == data.email, col(User.deleted_at).is_(None), s=session).one_or_none() if not user: - logger.warning( - "Direct password reset failed: user not found", - extra={"email": data.email} - ) + logger.warning("Direct password reset failed: user not found") raise UserException(code.error, _("User with this email not found")) # Update password @@ -209,7 +206,7 @@ async def reset_password_direct( logger.info( "Direct password reset successful", - extra={"user_id": user.id, "email": user.email} + extra={"user_id": user.id} ) return { From ed2c763faec7aae16668e9a0d656fa46062d0338 Mon Sep 17 00:00:00 2001 From: bitloi Date: Mon, 26 Jan 2026 14:03:55 +0100 Subject: [PATCH 05/14] fix(security): avoid logging email in password reset flow --- backend/app/controller/password_reset_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py index 8908e59ce..969044168 100644 --- a/backend/app/controller/password_reset_controller.py +++ b/backend/app/controller/password_reset_controller.py @@ -37,7 +37,7 @@ async def forgot_password(data: ForgotPasswordRequest): In development, returns the token directly for testing. """ email = data.email - logger.info(f"Password reset requested for email: {email}") + logger.info("Password reset requested") # Generate token token = generate_reset_token() From 3ad5d0bd4ad369f65685f821a192eaf505b2f22a Mon Sep 17 00:00:00 2001 From: bitloi Date: Mon, 26 Jan 2026 14:13:36 +0100 Subject: [PATCH 06/14] fix: resolve router logging conflict and keep password reset router --- backend/app/controller/password_reset_controller.py | 4 ++-- backend/app/router.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py index 969044168..28e2540cb 100644 --- a/backend/app/controller/password_reset_controller.py +++ b/backend/app/controller/password_reset_controller.py @@ -7,14 +7,14 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse from pydantic import ValidationError +import logging from app.model.password_reset import ( DirectResetPasswordRequest, ForgotPasswordRequest, ResetPasswordRequest, ) -from utils import traceroot_wrapper as traceroot -logger = traceroot.get_logger("password_reset_controller") +logger = logging.getLogger("password_reset_controller") router = APIRouter() diff --git a/backend/app/router.py b/backend/app/router.py index a03c34dcb..b4761eb54 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -18,7 +18,6 @@ """ from fastapi import FastAPI from app.controller import chat_controller, model_controller, task_controller, tool_controller, health_controller, password_reset_controller -from utils import traceroot_wrapper as traceroot import logging logger = logging.getLogger("router") From 2285019a07339e0bbf8141f36977d1ee9825fa68 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 27 Jan 2026 16:17:06 +0100 Subject: [PATCH 07/14] refactor(#884): Simplify Electron backend password reset to only reset_password_direct - Remove token-based functions (forgot_password, verify_reset_token, reset_password) - Remove traceroot_wrapper dependency (deleted upstream) - Use standard logging --- backend/app/controller/password_reset_controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py index 28e2540cb..8908e59ce 100644 --- a/backend/app/controller/password_reset_controller.py +++ b/backend/app/controller/password_reset_controller.py @@ -7,14 +7,14 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse from pydantic import ValidationError -import logging from app.model.password_reset import ( DirectResetPasswordRequest, ForgotPasswordRequest, ResetPasswordRequest, ) +from utils import traceroot_wrapper as traceroot -logger = logging.getLogger("password_reset_controller") +logger = traceroot.get_logger("password_reset_controller") router = APIRouter() @@ -37,7 +37,7 @@ async def forgot_password(data: ForgotPasswordRequest): In development, returns the token directly for testing. """ email = data.email - logger.info("Password reset requested") + logger.info(f"Password reset requested for email: {email}") # Generate token token = generate_reset_token() From 52f2d809f0259bc0137f32b9db6ff11db8ca128f Mon Sep 17 00:00:00 2001 From: LuoPengcheng <2653972504@qq.com> Date: Tue, 27 Jan 2026 23:57:05 +0800 Subject: [PATCH 08/14] minor log update --- backend/app/controller/password_reset_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py index 8908e59ce..79978a33e 100644 --- a/backend/app/controller/password_reset_controller.py +++ b/backend/app/controller/password_reset_controller.py @@ -12,9 +12,9 @@ ForgotPasswordRequest, ResetPasswordRequest, ) -from utils import traceroot_wrapper as traceroot +import logging -logger = traceroot.get_logger("password_reset_controller") +logger = logging.getLogger("password_reset_controller") router = APIRouter() From 146658f32bbcf8ad544c3be25c2e814d99f166a1 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 27 Jan 2026 17:01:53 +0100 Subject: [PATCH 09/14] fix(#884): Remove traceroot_wrapper, simplify to only reset_password_direct - Remove traceroot_wrapper import (deleted upstream) - Use standard logging instead - Keep only reset_password_direct function per PR feedback - Remove unused token-based functions --- .../controller/password_reset_controller.py | 106 +----------------- 1 file changed, 5 insertions(+), 101 deletions(-) diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py index 8908e59ce..b867671f3 100644 --- a/backend/app/controller/password_reset_controller.py +++ b/backend/app/controller/password_reset_controller.py @@ -1,111 +1,15 @@ """ Password Reset Controller -Handles forgot password and reset password functionality. +Handles direct password reset for Full Local Deployment. """ -import secrets -from datetime import datetime, timedelta -from fastapi import APIRouter, HTTPException -from fastapi.responses import JSONResponse -from pydantic import ValidationError -from app.model.password_reset import ( - DirectResetPasswordRequest, - ForgotPasswordRequest, - ResetPasswordRequest, -) -from utils import traceroot_wrapper as traceroot +import logging +from fastapi import APIRouter +from app.model.password_reset import DirectResetPasswordRequest -logger = traceroot.get_logger("password_reset_controller") +logger = logging.getLogger("password_reset_controller") router = APIRouter() -# In-memory token storage for development (in production, use database) -password_reset_tokens = {} - -TOKEN_EXPIRATION_HOURS = 24 - - -def generate_reset_token() -> str: - """Generate a secure random token for password reset.""" - return secrets.token_urlsafe(32) - - -@router.post("/forgot-password", name="request password reset") -async def forgot_password(data: ForgotPasswordRequest): - """ - Request a password reset link. - For security, always returns success even if email doesn't exist. - In development, returns the token directly for testing. - """ - email = data.email - logger.info(f"Password reset requested for email: {email}") - - # Generate token - token = generate_reset_token() - expires_at = datetime.now() + timedelta(hours=TOKEN_EXPIRATION_HOURS) - - # Store token (in production, this would be in database) - password_reset_tokens[token] = { - "email": email, - "expires_at": expires_at, - "used": False - } - - # In development, return the token for testing - return { - "status": "success", - "message": "If an account with that email exists, a password reset link has been sent.", - "reset_token": token, # Only for development - "expires_at": expires_at.isoformat(), - } - - -@router.get("/verify-reset-token/{token}", name="verify reset token") -async def verify_reset_token(token: str): - """ - Verify if a password reset token is valid. - """ - token_data = password_reset_tokens.get(token) - - if not token_data: - return {"valid": False, "message": "Invalid or expired reset token."} - - if token_data["used"]: - return {"valid": False, "message": "This reset token has already been used."} - - if datetime.now() > token_data["expires_at"]: - return {"valid": False, "message": "This reset token has expired."} - - return {"valid": True, "message": "Token is valid."} - - -@router.post("/reset-password", name="reset password") -async def reset_password(data: ResetPasswordRequest): - """ - Reset password using a valid token. - Password validation is handled by Pydantic model. - """ - # Verify token - token_data = password_reset_tokens.get(data.token) - - if not token_data: - raise HTTPException(status_code=400, detail="Invalid or expired reset token.") - - if token_data["used"]: - raise HTTPException(status_code=400, detail="This reset token has already been used.") - - if datetime.now() > token_data["expires_at"]: - raise HTTPException(status_code=400, detail="This reset token has expired.") - - # Mark token as used - token_data["used"] = True - - logger.info("Password reset successful") - - return { - "status": "success", - "message": "Your password has been reset successfully." - } - @router.post("/reset-password-direct", name="reset password directly") async def reset_password_direct(data: DirectResetPasswordRequest): From 9bf12b62a20fd97edfc27a056b111dccafb5be3c Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 27 Jan 2026 17:38:30 +0100 Subject: [PATCH 10/14] feat(#884): Implement proxy to server backend for password reset - Electron backend proxies reset_password_direct to server backend (port 8000) - Server backend performs actual database update - Uses httpx for async HTTP requests - Configurable via SERVER_BACKEND_URL env var --- .../controller/password_reset_controller.py | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py index b867671f3..93d429e23 100644 --- a/backend/app/controller/password_reset_controller.py +++ b/backend/app/controller/password_reset_controller.py @@ -1,15 +1,21 @@ """ Password Reset Controller Handles direct password reset for Full Local Deployment. +Proxies requests to the server backend which has database access. """ import logging -from fastapi import APIRouter +import os +import httpx +from fastapi import APIRouter, HTTPException from app.model.password_reset import DirectResetPasswordRequest logger = logging.getLogger("password_reset_controller") router = APIRouter() +# Server backend URL for database operations +SERVER_BACKEND_URL = os.getenv("SERVER_BACKEND_URL", "http://localhost:8000") + @router.post("/reset-password-direct", name="reset password directly") async def reset_password_direct(data: DirectResetPasswordRequest): @@ -18,17 +24,32 @@ async def reset_password_direct(data: DirectResetPasswordRequest): This endpoint is for Full Local Deployment only where email verification is not needed. Password validation is handled by Pydantic model. - Note: This is a simplified implementation for the Electron backend. - The actual password update happens in the server backend for Docker deployments. + Proxies the request to the server backend which performs the actual database update. """ - logger.info("Direct password reset requested") - - # Note: In the Electron backend, this endpoint acts as a proxy. - # The actual password update is handled by the server backend for Docker deployments. - # For now, return success - the frontend will call the server backend directly - # when VITE_USE_LOCAL_PROXY=true + logger.info("Direct password reset requested, proxying to server backend") - return { - "status": "success", - "message": "Password has been reset successfully. You can now log in with your new password." - } + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{SERVER_BACKEND_URL}/api/reset-password-direct", + json={ + "email": data.email, + "new_password": data.new_password, + "confirm_password": data.confirm_password, + } + ) + + if response.status_code == 200: + logger.info("Direct password reset successful") + return response.json() + else: + logger.warning(f"Server backend returned status {response.status_code}") + error_detail = response.json().get("text", "Password reset failed") + raise HTTPException(status_code=response.status_code, detail=error_detail) + + except httpx.RequestError as e: + logger.error(f"Failed to connect to server backend: {e}") + raise HTTPException( + status_code=503, + detail="Unable to connect to server backend. Please ensure the server is running." + ) From a37680f07134ec2cef6e661c1d4d261a33195250 Mon Sep 17 00:00:00 2001 From: LuoPengcheng <2653972504@qq.com> Date: Wed, 28 Jan 2026 01:42:31 +0800 Subject: [PATCH 11/14] minor log update & remove redundant func --- .../controller/password_reset_controller.py | 130 -------------- backend/app/model/password_reset.py | 102 ----------- backend/app/router.py | 8 +- .../user/password_reset_controller.py | 167 +----------------- server/docker-compose.yml | 10 +- server/lang/zh_CN/LC_MESSAGES/messages.po | 147 ++++++++------- server/messages.pot | 148 +++++++++------- 7 files changed, 184 insertions(+), 528 deletions(-) delete mode 100644 backend/app/controller/password_reset_controller.py delete mode 100644 backend/app/model/password_reset.py diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py deleted file mode 100644 index 79978a33e..000000000 --- a/backend/app/controller/password_reset_controller.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -Password Reset Controller -Handles forgot password and reset password functionality. -""" -import secrets -from datetime import datetime, timedelta -from fastapi import APIRouter, HTTPException -from fastapi.responses import JSONResponse -from pydantic import ValidationError -from app.model.password_reset import ( - DirectResetPasswordRequest, - ForgotPasswordRequest, - ResetPasswordRequest, -) -import logging - -logger = logging.getLogger("password_reset_controller") - -router = APIRouter() - -# In-memory token storage for development (in production, use database) -password_reset_tokens = {} - -TOKEN_EXPIRATION_HOURS = 24 - - -def generate_reset_token() -> str: - """Generate a secure random token for password reset.""" - return secrets.token_urlsafe(32) - - -@router.post("/forgot-password", name="request password reset") -async def forgot_password(data: ForgotPasswordRequest): - """ - Request a password reset link. - For security, always returns success even if email doesn't exist. - In development, returns the token directly for testing. - """ - email = data.email - logger.info(f"Password reset requested for email: {email}") - - # Generate token - token = generate_reset_token() - expires_at = datetime.now() + timedelta(hours=TOKEN_EXPIRATION_HOURS) - - # Store token (in production, this would be in database) - password_reset_tokens[token] = { - "email": email, - "expires_at": expires_at, - "used": False - } - - # In development, return the token for testing - return { - "status": "success", - "message": "If an account with that email exists, a password reset link has been sent.", - "reset_token": token, # Only for development - "expires_at": expires_at.isoformat(), - } - - -@router.get("/verify-reset-token/{token}", name="verify reset token") -async def verify_reset_token(token: str): - """ - Verify if a password reset token is valid. - """ - token_data = password_reset_tokens.get(token) - - if not token_data: - return {"valid": False, "message": "Invalid or expired reset token."} - - if token_data["used"]: - return {"valid": False, "message": "This reset token has already been used."} - - if datetime.now() > token_data["expires_at"]: - return {"valid": False, "message": "This reset token has expired."} - - return {"valid": True, "message": "Token is valid."} - - -@router.post("/reset-password", name="reset password") -async def reset_password(data: ResetPasswordRequest): - """ - Reset password using a valid token. - Password validation is handled by Pydantic model. - """ - # Verify token - token_data = password_reset_tokens.get(data.token) - - if not token_data: - raise HTTPException(status_code=400, detail="Invalid or expired reset token.") - - if token_data["used"]: - raise HTTPException(status_code=400, detail="This reset token has already been used.") - - if datetime.now() > token_data["expires_at"]: - raise HTTPException(status_code=400, detail="This reset token has expired.") - - # Mark token as used - token_data["used"] = True - - logger.info("Password reset successful") - - return { - "status": "success", - "message": "Your password has been reset successfully." - } - - -@router.post("/reset-password-direct", name="reset password directly") -async def reset_password_direct(data: DirectResetPasswordRequest): - """ - Reset password directly without token verification. - This endpoint is for Full Local Deployment only where email verification is not needed. - Password validation is handled by Pydantic model. - - Note: This is a simplified implementation for the Electron backend. - The actual password update happens in the server backend for Docker deployments. - """ - logger.info("Direct password reset requested") - - # Note: In the Electron backend, this endpoint acts as a proxy. - # The actual password update is handled by the server backend for Docker deployments. - # For now, return success - the frontend will call the server backend directly - # when VITE_USE_LOCAL_PROXY=true - - return { - "status": "success", - "message": "Password has been reset successfully. You can now log in with your new password." - } diff --git a/backend/app/model/password_reset.py b/backend/app/model/password_reset.py deleted file mode 100644 index cfb8b8223..000000000 --- a/backend/app/model/password_reset.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Password Reset Models -Pydantic models for password reset functionality with validation. -""" -from pydantic import BaseModel, field_validator, model_validator - - -class DirectResetPasswordRequest(BaseModel): - """Request model for direct password reset (local deployment only).""" - email: str - new_password: str - confirm_password: str - - @field_validator("email") - @classmethod - def validate_email(cls, v: str) -> str: - """Validate email format.""" - v = v.strip().lower() - if not v: - raise ValueError("Email is required") - if "@" not in v or "." not in v.split("@")[-1]: - raise ValueError("Invalid email format") - return v - - @field_validator("new_password") - @classmethod - def validate_password_strength(cls, v: str) -> str: - """Validate password meets strength requirements.""" - if len(v) < 8: - raise ValueError("Password must be at least 8 characters long") - - has_letter = any(c.isalpha() for c in v) - has_number = any(c.isdigit() for c in v) - - if not has_letter: - raise ValueError("Password must contain at least one letter") - if not has_number: - raise ValueError("Password must contain at least one number") - - return v - - @model_validator(mode="after") - def validate_passwords_match(self): - """Validate that new_password and confirm_password match.""" - if self.new_password != self.confirm_password: - raise ValueError("Passwords do not match") - return self - - -class ForgotPasswordRequest(BaseModel): - """Request model for forgot password (token-based flow).""" - email: str - - @field_validator("email") - @classmethod - def validate_email(cls, v: str) -> str: - """Validate email format.""" - v = v.strip().lower() - if not v: - raise ValueError("Email is required") - if "@" not in v or "." not in v.split("@")[-1]: - raise ValueError("Invalid email format") - return v - - -class ResetPasswordRequest(BaseModel): - """Request model for reset password with token.""" - token: str - new_password: str - confirm_password: str - - @field_validator("token") - @classmethod - def validate_token(cls, v: str) -> str: - """Validate token is not empty.""" - if not v or not v.strip(): - raise ValueError("Token is required") - return v.strip() - - @field_validator("new_password") - @classmethod - def validate_password_strength(cls, v: str) -> str: - """Validate password meets strength requirements.""" - if len(v) < 8: - raise ValueError("Password must be at least 8 characters long") - - has_letter = any(c.isalpha() for c in v) - has_number = any(c.isdigit() for c in v) - - if not has_letter: - raise ValueError("Password must contain at least one letter") - if not has_number: - raise ValueError("Password must contain at least one number") - - return v - - @model_validator(mode="after") - def validate_passwords_match(self): - """Validate that new_password and confirm_password match.""" - if self.new_password != self.confirm_password: - raise ValueError("Passwords do not match") - return self diff --git a/backend/app/router.py b/backend/app/router.py index b4761eb54..a07ba9312 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -17,7 +17,7 @@ All routers are explicitly registered here for better visibility and maintainability. """ from fastapi import FastAPI -from app.controller import chat_controller, model_controller, task_controller, tool_controller, health_controller, password_reset_controller +from app.controller import chat_controller, model_controller, task_controller, tool_controller, health_controller import logging logger = logging.getLogger("router") @@ -62,11 +62,7 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: "tags": ["tool"], "description": "Tool installation and management" }, - { - "router": password_reset_controller.router, - "tags": ["auth"], - "description": "Password reset functionality" - }, + ] for config in routers_config: diff --git a/server/app/controller/user/password_reset_controller.py b/server/app/controller/user/password_reset_controller.py index 9debb639b..1fe0eb05f 100644 --- a/server/app/controller/user/password_reset_controller.py +++ b/server/app/controller/user/password_reset_controller.py @@ -1,6 +1,9 @@ +import logging import secrets from datetime import datetime, timedelta + from fastapi import APIRouter, Depends +from fastapi_babel import _ from sqlmodel import Session, col from app.component import code @@ -14,175 +17,13 @@ ResetPasswordRequest, ) from app.model.user.user import User -from fastapi_babel import _ -from utils import traceroot_wrapper as traceroot -logger = traceroot.get_logger("server_password_reset_controller") +logger = logging.getLogger("server_password_reset_controller") router = APIRouter(tags=["Password Reset"]) -# Token expiration time in hours -TOKEN_EXPIRATION_HOURS = 24 - - -def generate_reset_token() -> str: - """Generate a secure random token for password reset.""" - return secrets.token_urlsafe(32) - - -@router.post("/forgot-password", name="request password reset") -@traceroot.trace() -async def forgot_password( - data: ForgotPasswordRequest, - session: Session = Depends(session), -): - """ - Request a password reset. Generates a reset token for the user. - - For security reasons, always returns success even if email doesn't exist. - In production, this would send an email with the reset link. - """ - email = data.email - user = User.by(User.email == email, col(User.deleted_at).is_(None), s=session).one_or_none() - - if user: - # Invalidate any existing unused tokens for this user - existing_tokens = PasswordResetToken.by( - PasswordResetToken.user_id == user.id, - PasswordResetToken.used == False, # noqa: E712 - col(PasswordResetToken.deleted_at).is_(None), - s=session - ).all() - - for token in existing_tokens: - token.used = True - token.save(session) - - # Generate new token - token = generate_reset_token() - expires_at = datetime.now() + timedelta(hours=TOKEN_EXPIRATION_HOURS) - - reset_token = PasswordResetToken( - user_id=user.id, - token=token, - expires_at=expires_at, - ) - reset_token.save(session) - - logger.info( - "Password reset token generated", - extra={"user_id": user.id, "email": email, "expires_at": str(expires_at)} - ) - - # TODO: In production, send email with reset link - # For now, return the token in response (development only) - # In production, remove token from response and send via email - return { - "status": "success", - "message": "If an account with that email exists, a password reset link has been sent.", - # Development only - remove in production - "reset_token": token, - "expires_at": expires_at.isoformat(), - } - else: - logger.warning( - "Password reset requested for non-existent email", - extra={"email": email} - ) - - # Always return success for security (don't reveal if email exists) - return { - "status": "success", - "message": "If an account with that email exists, a password reset link has been sent.", - } - - -@router.post("/reset-password", name="reset password with token") -@traceroot.trace() -async def reset_password( - data: ResetPasswordRequest, - session: Session = Depends(session), -): - """ - Reset password using a valid reset token. - Password validation is handled by Pydantic model. - """ - # Find the token - reset_token = PasswordResetToken.by( - PasswordResetToken.token == data.token, - col(PasswordResetToken.deleted_at).is_(None), - s=session - ).one_or_none() - - if not reset_token: - logger.warning("Password reset failed: invalid token") - raise UserException(code.error, _("Invalid or expired reset token")) - - if not reset_token.is_valid(): - logger.warning( - "Password reset failed: token expired or used", - extra={"token_id": reset_token.id, "used": reset_token.used} - ) - raise UserException(code.error, _("Invalid or expired reset token")) - - # Get the user - user = session.get(User, reset_token.user_id) - if not user: - logger.error( - "Password reset failed: user not found", - extra={"user_id": reset_token.user_id} - ) - raise UserException(code.error, _("User not found")) - - # Update password - user.password = password_hash(data.new_password) - user.save(session) - - # Mark token as used - reset_token.used = True - reset_token.save(session) - - logger.info( - "Password reset successful", - extra={"user_id": user.id} - ) - - return { - "status": "success", - "message": "Password has been reset successfully. You can now log in with your new password.", - } - - -@router.get("/verify-reset-token/{token}", name="verify reset token") -@traceroot.trace() -async def verify_reset_token( - token: str, - session: Session = Depends(session), -): - """ - Verify if a reset token is valid. - Used by frontend to check token before showing reset form. - """ - reset_token = PasswordResetToken.by( - PasswordResetToken.token == token, - col(PasswordResetToken.deleted_at).is_(None), - s=session - ).one_or_none() - - if not reset_token or not reset_token.is_valid(): - return { - "valid": False, - "message": "Invalid or expired reset token", - } - - return { - "valid": True, - "message": "Token is valid", - } - @router.post("/reset-password-direct", name="reset password directly") -@traceroot.trace() async def reset_password_direct( data: DirectResetPasswordRequest, session: Session = Depends(session), diff --git a/server/docker-compose.yml b/server/docker-compose.yml index a4dbba26e..e2c2e9323 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -37,11 +37,11 @@ services: - DATABASE_URL=postgresql://postgres:123456@postgres:5432/eigent - ENVIRONMENT=production - DEBUG=false - # volumes: - # - ./app:/app/app - # - ./alembic:/app/alembic - # - ./lang:/app/lang - # - ./public:/app/public + volumes: + - ./app:/app/app + - ./alembic:/app/alembic + - ./lang:/app/lang + - ./public:/app/public depends_on: postgres: condition: service_healthy diff --git a/server/lang/zh_CN/LC_MESSAGES/messages.po b/server/lang/zh_CN/LC_MESSAGES/messages.po index 3438d4d64..a696c7d2d 100644 --- a/server/lang/zh_CN/LC_MESSAGES/messages.po +++ b/server/lang/zh_CN/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-08-06 09:56+0800\n" +"POT-Creation-Date: 2026-01-28 01:03+0800\n" "PO-Revision-Date: 2025-08-06 09:56+0800\n" "Last-Translator: FULL NAME \n" "Language: zh_Hans_CN\n" @@ -18,199 +18,226 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: app/component/auth.py:41 +#: app/component/auth.py:55 msgid "Validate credentials expired" msgstr "" -#: app/component/auth.py:43 +#: app/component/auth.py:57 msgid "Could not validate credentials" msgstr "" -#: app/component/permission.py:12 +#: app/component/permission.py:26 msgid "User" msgstr "" -#: app/component/permission.py:13 +#: app/component/permission.py:27 msgid "User manager" msgstr "" -#: app/component/permission.py:17 +#: app/component/permission.py:31 msgid "User Manage" msgstr "" -#: app/component/permission.py:18 +#: app/component/permission.py:32 msgid "View users" msgstr "" -#: app/component/permission.py:22 +#: app/component/permission.py:36 msgid "User Edit" msgstr "" -#: app/component/permission.py:23 +#: app/component/permission.py:37 msgid "Manage users" msgstr "" -#: app/component/permission.py:28 +#: app/component/permission.py:42 msgid "Admin" msgstr "" -#: app/component/permission.py:29 +#: app/component/permission.py:43 msgid "Admin manager" msgstr "" -#: app/component/permission.py:33 +#: app/component/permission.py:47 msgid "Admin View" msgstr "" -#: app/component/permission.py:34 +#: app/component/permission.py:48 msgid "View admins" msgstr "" -#: app/component/permission.py:38 +#: app/component/permission.py:52 msgid "Admin Edit" msgstr "" -#: app/component/permission.py:39 +#: app/component/permission.py:53 msgid "Edit admins" msgstr "" -#: app/component/permission.py:44 +#: app/component/permission.py:58 msgid "Role" msgstr "" -#: app/component/permission.py:45 +#: app/component/permission.py:59 msgid "Role manager" msgstr "" -#: app/component/permission.py:49 +#: app/component/permission.py:63 msgid "Role View" msgstr "" -#: app/component/permission.py:50 +#: app/component/permission.py:64 msgid "View roles" msgstr "" -#: app/component/permission.py:54 +#: app/component/permission.py:68 msgid "Role Edit" msgstr "" -#: app/component/permission.py:55 +#: app/component/permission.py:69 msgid "Edit roles" msgstr "" -#: app/component/permission.py:60 +#: app/component/permission.py:74 msgid "Mcp" msgstr "" -#: app/component/permission.py:61 +#: app/component/permission.py:75 msgid "Mcp manager" msgstr "" -#: app/component/permission.py:65 +#: app/component/permission.py:79 msgid "Mcp Edit" msgstr "" -#: app/component/permission.py:66 +#: app/component/permission.py:80 msgid "Edit mcp service" msgstr "" -#: app/component/permission.py:70 +#: app/component/permission.py:84 msgid "Mcp Category Edit" msgstr "" -#: app/component/permission.py:71 +#: app/component/permission.py:85 msgid "Edit mcp category" msgstr "" -#: app/controller/chat/snapshot_controller.py:34 -#: app/controller/chat/snapshot_controller.py:68 -#: app/controller/chat/snapshot_controller.py:81 +#: app/controller/chat/snapshot_controller.py:58 +#: app/controller/chat/snapshot_controller.py:104 +#: app/controller/chat/snapshot_controller.py:133 msgid "Chat snapshot not found" msgstr "" -#: app/controller/chat/step_controller.py:65 -#: app/controller/chat/step_controller.py:89 -#: app/controller/chat/step_controller.py:102 +#: app/controller/chat/snapshot_controller.py:108 +msgid "You are not allowed to update this snapshot" +msgstr "" + +#: app/controller/chat/snapshot_controller.py:137 +msgid "You are not allowed to delete this snapshot" +msgstr "" + +#: app/controller/chat/step_controller.py:105 +#: app/controller/chat/step_controller.py:142 +#: app/controller/chat/step_controller.py:167 msgid "Chat step not found" msgstr "" -#: app/controller/config/config_controller.py:40 -#: app/controller/config/config_controller.py:76 -#: app/controller/config/config_controller.py:108 +#: app/controller/config/config_controller.py:60 +#: app/controller/config/config_controller.py:112 +#: app/controller/config/config_controller.py:155 msgid "Configuration not found" msgstr "" -#: app/controller/config/config_controller.py:47 -msgid "Config Name is valid" +#: app/controller/config/config_controller.py:73 +msgid "Invalid config name or group" msgstr "" -#: app/controller/config/config_controller.py:55 -#: app/controller/config/config_controller.py:92 +#: app/controller/config/config_controller.py:82 +#: app/controller/config/config_controller.py:130 msgid "Configuration already exists for this user" msgstr "" -#: app/controller/config/config_controller.py:80 +#: app/controller/config/config_controller.py:117 msgid "Invalid configuration group" msgstr "" -#: app/controller/mcp/mcp_controller.py:70 +#: app/controller/mcp/mcp_controller.py:132 +#: app/controller/mcp/mcp_controller.py:143 msgid "Mcp not found" msgstr "" -#: app/controller/mcp/mcp_controller.py:73 -#: app/controller/mcp/user_controller.py:44 +#: app/controller/mcp/mcp_controller.py:148 +#: app/controller/mcp/user_controller.py:113 msgid "mcp is installed" msgstr "" -#: app/controller/mcp/user_controller.py:34 +#: app/controller/mcp/user_controller.py:97 msgid "McpUser not found" msgstr "" -#: app/controller/mcp/user_controller.py:61 -#: app/controller/mcp/user_controller.py:75 +#: app/controller/mcp/user_controller.py:156 +#: app/controller/mcp/user_controller.py:180 msgid "Mcp Info not found" msgstr "" -#: app/controller/mcp/user_controller.py:63 +#: app/controller/mcp/user_controller.py:159 msgid "current user have no permission to modify" msgstr "" -#: app/controller/provider/provider_controller.py:41 -#: app/controller/provider/provider_controller.py:60 -#: app/controller/provider/provider_controller.py:79 +#: app/controller/provider/provider_controller.py:61 +#: app/controller/provider/provider_controller.py:89 +#: app/controller/provider/provider_controller.py:116 msgid "Provider not found" msgstr "" -#: app/controller/user/login_controller.py:25 +#: app/controller/user/login_controller.py:52 +#: app/controller/user/login_controller.py:58 msgid "Account or password error" msgstr "" -#: app/controller/user/login_controller.py:47 +#: app/controller/user/login_controller.py:110 +msgid "Authentication failed" +msgstr "" + +#: app/controller/user/login_controller.py:120 +#: app/controller/user/password_reset_controller.py:135 msgid "User not found" msgstr "" -#: app/controller/user/login_controller.py:64 -#: app/controller/user/login_controller.py:89 +#: app/controller/user/login_controller.py:152 +#: app/controller/user/login_controller.py:198 msgid "Failed to register" msgstr "" -#: app/controller/user/login_controller.py:67 +#: app/controller/user/login_controller.py:159 msgid "Your account has been blocked." msgstr "" -#: app/controller/user/login_controller.py:75 +#: app/controller/user/login_controller.py:176 msgid "Email already registered" msgstr "" -#: app/controller/user/user_password_controller.py:19 +#: app/controller/user/password_reset_controller.py:119 +#: app/controller/user/password_reset_controller.py:126 +msgid "Invalid or expired reset token" +msgstr "" + +#: app/controller/user/password_reset_controller.py:201 +msgid "User with this email not found" +msgstr "" + +#: app/controller/user/user_password_controller.py:40 msgid "Password is incorrect" msgstr "" -#: app/controller/user/user_password_controller.py:21 +#: app/controller/user/user_password_controller.py:44 msgid "The two passwords do not match" msgstr "" -#: app/model/abstract/model.py:66 +#: app/model/abstract/model.py:97 msgid "There is no data that meets the conditions" msgstr "" +#~ msgid "Config Name is valid" +#~ msgstr "" + diff --git a/server/messages.pot b/server/messages.pot index f9fb46113..ff950ec13 100644 --- a/server/messages.pot +++ b/server/messages.pot @@ -1,14 +1,14 @@ # Translations template for PROJECT. -# Copyright (C) 2025 ORGANIZATION +# Copyright (C) 2026 ORGANIZATION # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2025. +# FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-08-06 09:56+0800\n" +"POT-Creation-Date: 2026-01-28 01:03+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,199 +17,223 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: app/component/auth.py:41 +#: app/component/auth.py:55 msgid "Validate credentials expired" msgstr "" -#: app/component/auth.py:43 +#: app/component/auth.py:57 msgid "Could not validate credentials" msgstr "" -#: app/component/permission.py:12 +#: app/component/permission.py:26 msgid "User" msgstr "" -#: app/component/permission.py:13 +#: app/component/permission.py:27 msgid "User manager" msgstr "" -#: app/component/permission.py:17 +#: app/component/permission.py:31 msgid "User Manage" msgstr "" -#: app/component/permission.py:18 +#: app/component/permission.py:32 msgid "View users" msgstr "" -#: app/component/permission.py:22 +#: app/component/permission.py:36 msgid "User Edit" msgstr "" -#: app/component/permission.py:23 +#: app/component/permission.py:37 msgid "Manage users" msgstr "" -#: app/component/permission.py:28 +#: app/component/permission.py:42 msgid "Admin" msgstr "" -#: app/component/permission.py:29 +#: app/component/permission.py:43 msgid "Admin manager" msgstr "" -#: app/component/permission.py:33 +#: app/component/permission.py:47 msgid "Admin View" msgstr "" -#: app/component/permission.py:34 +#: app/component/permission.py:48 msgid "View admins" msgstr "" -#: app/component/permission.py:38 +#: app/component/permission.py:52 msgid "Admin Edit" msgstr "" -#: app/component/permission.py:39 +#: app/component/permission.py:53 msgid "Edit admins" msgstr "" -#: app/component/permission.py:44 +#: app/component/permission.py:58 msgid "Role" msgstr "" -#: app/component/permission.py:45 +#: app/component/permission.py:59 msgid "Role manager" msgstr "" -#: app/component/permission.py:49 +#: app/component/permission.py:63 msgid "Role View" msgstr "" -#: app/component/permission.py:50 +#: app/component/permission.py:64 msgid "View roles" msgstr "" -#: app/component/permission.py:54 +#: app/component/permission.py:68 msgid "Role Edit" msgstr "" -#: app/component/permission.py:55 +#: app/component/permission.py:69 msgid "Edit roles" msgstr "" -#: app/component/permission.py:60 +#: app/component/permission.py:74 msgid "Mcp" msgstr "" -#: app/component/permission.py:61 +#: app/component/permission.py:75 msgid "Mcp manager" msgstr "" -#: app/component/permission.py:65 +#: app/component/permission.py:79 msgid "Mcp Edit" msgstr "" -#: app/component/permission.py:66 +#: app/component/permission.py:80 msgid "Edit mcp service" msgstr "" -#: app/component/permission.py:70 +#: app/component/permission.py:84 msgid "Mcp Category Edit" msgstr "" -#: app/component/permission.py:71 +#: app/component/permission.py:85 msgid "Edit mcp category" msgstr "" -#: app/controller/chat/snapshot_controller.py:34 -#: app/controller/chat/snapshot_controller.py:68 -#: app/controller/chat/snapshot_controller.py:81 +#: app/controller/chat/snapshot_controller.py:58 +#: app/controller/chat/snapshot_controller.py:104 +#: app/controller/chat/snapshot_controller.py:133 msgid "Chat snapshot not found" msgstr "" -#: app/controller/chat/step_controller.py:65 -#: app/controller/chat/step_controller.py:89 -#: app/controller/chat/step_controller.py:102 +#: app/controller/chat/snapshot_controller.py:108 +msgid "You are not allowed to update this snapshot" +msgstr "" + +#: app/controller/chat/snapshot_controller.py:137 +msgid "You are not allowed to delete this snapshot" +msgstr "" + +#: app/controller/chat/step_controller.py:105 +#: app/controller/chat/step_controller.py:142 +#: app/controller/chat/step_controller.py:167 msgid "Chat step not found" msgstr "" -#: app/controller/config/config_controller.py:40 -#: app/controller/config/config_controller.py:76 -#: app/controller/config/config_controller.py:108 +#: app/controller/config/config_controller.py:60 +#: app/controller/config/config_controller.py:112 +#: app/controller/config/config_controller.py:155 msgid "Configuration not found" msgstr "" -#: app/controller/config/config_controller.py:47 -msgid "Config Name is valid" +#: app/controller/config/config_controller.py:73 +msgid "Invalid config name or group" msgstr "" -#: app/controller/config/config_controller.py:55 -#: app/controller/config/config_controller.py:92 +#: app/controller/config/config_controller.py:82 +#: app/controller/config/config_controller.py:130 msgid "Configuration already exists for this user" msgstr "" -#: app/controller/config/config_controller.py:80 +#: app/controller/config/config_controller.py:117 msgid "Invalid configuration group" msgstr "" -#: app/controller/mcp/mcp_controller.py:70 +#: app/controller/mcp/mcp_controller.py:132 +#: app/controller/mcp/mcp_controller.py:143 msgid "Mcp not found" msgstr "" -#: app/controller/mcp/mcp_controller.py:73 -#: app/controller/mcp/user_controller.py:44 +#: app/controller/mcp/mcp_controller.py:148 +#: app/controller/mcp/user_controller.py:113 msgid "mcp is installed" msgstr "" -#: app/controller/mcp/user_controller.py:34 +#: app/controller/mcp/user_controller.py:97 msgid "McpUser not found" msgstr "" -#: app/controller/mcp/user_controller.py:61 -#: app/controller/mcp/user_controller.py:75 +#: app/controller/mcp/user_controller.py:156 +#: app/controller/mcp/user_controller.py:180 msgid "Mcp Info not found" msgstr "" -#: app/controller/mcp/user_controller.py:63 +#: app/controller/mcp/user_controller.py:159 msgid "current user have no permission to modify" msgstr "" -#: app/controller/provider/provider_controller.py:41 -#: app/controller/provider/provider_controller.py:60 -#: app/controller/provider/provider_controller.py:79 +#: app/controller/provider/provider_controller.py:61 +#: app/controller/provider/provider_controller.py:89 +#: app/controller/provider/provider_controller.py:116 msgid "Provider not found" msgstr "" -#: app/controller/user/login_controller.py:25 +#: app/controller/user/login_controller.py:52 +#: app/controller/user/login_controller.py:58 msgid "Account or password error" msgstr "" -#: app/controller/user/login_controller.py:47 +#: app/controller/user/login_controller.py:110 +msgid "Authentication failed" +msgstr "" + +#: app/controller/user/login_controller.py:120 +#: app/controller/user/password_reset_controller.py:135 msgid "User not found" msgstr "" -#: app/controller/user/login_controller.py:64 -#: app/controller/user/login_controller.py:89 +#: app/controller/user/login_controller.py:152 +#: app/controller/user/login_controller.py:198 msgid "Failed to register" msgstr "" -#: app/controller/user/login_controller.py:67 +#: app/controller/user/login_controller.py:159 msgid "Your account has been blocked." msgstr "" -#: app/controller/user/login_controller.py:75 +#: app/controller/user/login_controller.py:176 msgid "Email already registered" msgstr "" -#: app/controller/user/user_password_controller.py:19 +#: app/controller/user/password_reset_controller.py:119 +#: app/controller/user/password_reset_controller.py:126 +msgid "Invalid or expired reset token" +msgstr "" + +#: app/controller/user/password_reset_controller.py:201 +msgid "User with this email not found" +msgstr "" + +#: app/controller/user/user_password_controller.py:40 msgid "Password is incorrect" msgstr "" -#: app/controller/user/user_password_controller.py:21 +#: app/controller/user/user_password_controller.py:44 msgid "The two passwords do not match" msgstr "" -#: app/model/abstract/model.py:66 +#: app/model/abstract/model.py:97 msgid "There is no data that meets the conditions" msgstr "" From 494e39066bf013eac2594e244962560e2ed955d1 Mon Sep 17 00:00:00 2001 From: LuoPengcheng <2653972504@qq.com> Date: Wed, 28 Jan 2026 02:12:06 +0800 Subject: [PATCH 12/14] remove redundant file --- .../controller/password_reset_controller.py | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 backend/app/controller/password_reset_controller.py diff --git a/backend/app/controller/password_reset_controller.py b/backend/app/controller/password_reset_controller.py deleted file mode 100644 index 93d429e23..000000000 --- a/backend/app/controller/password_reset_controller.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Password Reset Controller -Handles direct password reset for Full Local Deployment. -Proxies requests to the server backend which has database access. -""" -import logging -import os -import httpx -from fastapi import APIRouter, HTTPException -from app.model.password_reset import DirectResetPasswordRequest - -logger = logging.getLogger("password_reset_controller") - -router = APIRouter() - -# Server backend URL for database operations -SERVER_BACKEND_URL = os.getenv("SERVER_BACKEND_URL", "http://localhost:8000") - - -@router.post("/reset-password-direct", name="reset password directly") -async def reset_password_direct(data: DirectResetPasswordRequest): - """ - Reset password directly without token verification. - This endpoint is for Full Local Deployment only where email verification is not needed. - Password validation is handled by Pydantic model. - - Proxies the request to the server backend which performs the actual database update. - """ - logger.info("Direct password reset requested, proxying to server backend") - - try: - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - f"{SERVER_BACKEND_URL}/api/reset-password-direct", - json={ - "email": data.email, - "new_password": data.new_password, - "confirm_password": data.confirm_password, - } - ) - - if response.status_code == 200: - logger.info("Direct password reset successful") - return response.json() - else: - logger.warning(f"Server backend returned status {response.status_code}") - error_detail = response.json().get("text", "Password reset failed") - raise HTTPException(status_code=response.status_code, detail=error_detail) - - except httpx.RequestError as e: - logger.error(f"Failed to connect to server backend: {e}") - raise HTTPException( - status_code=503, - detail="Unable to connect to server backend. Please ensure the server is running." - ) From 4c489dfb7a1b05233bd30eeded60ea7b76efe70c Mon Sep 17 00:00:00 2001 From: LuoPengcheng <2653972504@qq.com> Date: Wed, 28 Jan 2026 02:12:59 +0800 Subject: [PATCH 13/14] minor lint update --- backend/app/router.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/router.py b/backend/app/router.py index a07ba9312..e1515266d 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -62,7 +62,6 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: "tags": ["tool"], "description": "Tool installation and management" }, - ] for config in routers_config: From 26d7e9d504e169715f893ddaa40e76859f1ad2fd Mon Sep 17 00:00:00 2001 From: bitloi Date: Thu, 12 Mar 2026 16:09:05 +0100 Subject: [PATCH 14/14] refactor: remove token-based password reset flow, redirect to /signin - Update end-user forgot-password redirect from /forgot-password to /signin - Remove PasswordResetToken model, ForgotPasswordRequest, ResetPasswordRequest - Neutralize password_reset_token alembic migration to no-op - Remove ResetPassword.tsx content and its route - Keep DirectResetPasswordRequest and /reset-password-direct for local deployment --- ..._19_1200-add_password_reset_token_table.py | 41 +-- .../user/password_reset_controller.py | 19 +- server/app/model/user/password_reset.py | 74 +---- src/pages/Login.tsx | 4 +- src/pages/ResetPassword.tsx | 290 +----------------- src/routers/index.tsx | 2 - 6 files changed, 65 insertions(+), 365 deletions(-) diff --git a/server/alembic/versions/2026_01_19_1200-add_password_reset_token_table.py b/server/alembic/versions/2026_01_19_1200-add_password_reset_token_table.py index 0a12b9acb..f0add6f08 100644 --- a/server/alembic/versions/2026_01_19_1200-add_password_reset_token_table.py +++ b/server/alembic/versions/2026_01_19_1200-add_password_reset_token_table.py @@ -1,3 +1,17 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + """add_password_reset_token_table Revision ID: add_password_reset_token @@ -19,26 +33,13 @@ def upgrade() -> None: - """Create password_reset_token table.""" - op.create_table( - "password_reset_token", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("token", sa.String(255), nullable=False), - sa.Column("expires_at", sa.DateTime(), nullable=False), - sa.Column("used", sa.Boolean(), nullable=False, server_default=sa.text("false")), - sa.Column("deleted_at", sa.DateTime(), nullable=True), - sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=True), - sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=True), - sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_password_reset_token_user_id", "password_reset_token", ["user_id"]) - op.create_index("ix_password_reset_token_token", "password_reset_token", ["token"], unique=True) + """No-op. + + The password_reset_token flow is no longer used. + """ + pass def downgrade() -> None: - """Drop password_reset_token table.""" - op.drop_index("ix_password_reset_token_token", table_name="password_reset_token") - op.drop_index("ix_password_reset_token_user_id", table_name="password_reset_token") - op.drop_table("password_reset_token") + """No-op.""" + pass diff --git a/server/app/controller/user/password_reset_controller.py b/server/app/controller/user/password_reset_controller.py index 1fe0eb05f..0a8eda067 100644 --- a/server/app/controller/user/password_reset_controller.py +++ b/server/app/controller/user/password_reset_controller.py @@ -1,6 +1,18 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + import logging -import secrets -from datetime import datetime, timedelta from fastapi import APIRouter, Depends from fastapi_babel import _ @@ -12,9 +24,6 @@ from app.exception.exception import UserException from app.model.user.password_reset import ( DirectResetPasswordRequest, - ForgotPasswordRequest, - PasswordResetToken, - ResetPasswordRequest, ) from app.model.user.user import User diff --git a/server/app/model/user/password_reset.py b/server/app/model/user/password_reset.py index 73b248248..bc5790db1 100644 --- a/server/app/model/user/password_reset.py +++ b/server/app/model/user/password_reset.py @@ -1,64 +1,18 @@ -from datetime import datetime -from sqlmodel import Field -from app.model.abstract.model import AbstractModel, DefaultTimes -from pydantic import BaseModel, EmailStr, field_validator, model_validator - - -class PasswordResetToken(AbstractModel, DefaultTimes, table=True): - """Model for storing password reset tokens.""" - id: int = Field(default=None, primary_key=True) - user_id: int = Field(index=True, foreign_key="user.id") - token: str = Field(unique=True, max_length=255, index=True) - expires_at: datetime = Field() - used: bool = Field(default=False) - - def is_valid(self) -> bool: - """Check if the token is still valid (not expired and not used).""" - return not self.used and datetime.now() < self.expires_at - +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -class ForgotPasswordRequest(BaseModel): - """Request model for forgot password endpoint.""" - email: EmailStr - - -class ResetPasswordRequest(BaseModel): - """Request model for reset password endpoint.""" - token: str - new_password: str - confirm_password: str - - @field_validator("token") - @classmethod - def validate_token(cls, v: str) -> str: - """Validate token is not empty.""" - if not v or not v.strip(): - raise ValueError("Token is required") - return v.strip() - - @field_validator("new_password") - @classmethod - def validate_password_strength(cls, v: str) -> str: - """Validate password meets strength requirements.""" - if len(v) < 8: - raise ValueError("Password must be at least 8 characters long") - - has_letter = any(c.isalpha() for c in v) - has_number = any(c.isdigit() for c in v) - - if not has_letter: - raise ValueError("Password must contain at least one letter") - if not has_number: - raise ValueError("Password must contain at least one number") - - return v - - @model_validator(mode="after") - def validate_passwords_match(self): - """Validate that new_password and confirm_password match.""" - if self.new_password != self.confirm_password: - raise ValueError("Passwords do not match") - return self +from pydantic import BaseModel, EmailStr, field_validator, model_validator class DirectResetPasswordRequest(BaseModel): diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index f4404f646..50a80c181 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -473,7 +473,7 @@ export default function Login() { /> -
+
- -
-
- - ); - } - - if (isSuccess) { - return ( -
-
- -
-
-
-
- {t('layout.password-reset-success')} -
-

- {t('layout.password-reset-success-description')} -

- -
-
-
- ); - } - - return ( -
-
- -
-
-
-
-
- {t('layout.reset-password')} -
-
-

- {t('layout.reset-password-description')} -

-
- {generalError && ( -

- {generalError} -

- )} -
- handleInputChange('newPassword', e.target.value)} - state={errors.newPassword ? 'error' : undefined} - note={errors.newPassword} - backIcon={} - onBackIconClick={() => setHidePassword(!hidePassword)} - /> - - handleInputChange('confirmPassword', e.target.value)} - state={errors.confirmPassword ? 'error' : undefined} - note={errors.confirmPassword} - backIcon={} - onBackIconClick={() => setHideConfirmPassword(!hideConfirmPassword)} - onEnter={handleSubmit} - /> -
-
- -
-
-
- ); + return null; } diff --git a/src/routers/index.tsx b/src/routers/index.tsx index bdb6f3150..efa80e38d 100644 --- a/src/routers/index.tsx +++ b/src/routers/index.tsx @@ -21,7 +21,6 @@ import Layout from '@/components/Layout'; const Login = lazy(() => import('@/pages/Login')); const Signup = lazy(() => import('@/pages/SignUp')); const ForgotPassword = lazy(() => import('@/pages/ForgotPassword')); -const ResetPassword = lazy(() => import('@/pages/ResetPassword')); const Home = lazy(() => import('@/pages/Home')); const History = lazy(() => import('@/pages/History')); const NotFound = lazy(() => import('@/pages/NotFound')); @@ -98,7 +97,6 @@ const AppRoutes = () => ( } /> } /> } /> - } /> }> }> } />