feat: email verification, merchant/store password reset, seed gap fix
Some checks failed
Some checks failed
- Add EmailVerificationToken and UserPasswordResetToken models with migration - Add email verification flow: verify-email page route, resend-verification API - Block login for unverified users (EmailNotVerifiedException in auth_service) - Add forgot-password/reset-password endpoints for merchant and store auth - Add "Forgot Password?" links to merchant and store login pages - Send welcome email with verification link on merchant creation - Seed email_verification and merchant_password_reset email templates - Fix db-reset Makefile to run all init-prod seed scripts - Add UserAuthService to satisfy architecture validation rules - Add 52 new tests (unit + integration) with full coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,18 @@ This prevents merchant cookies from being sent to admin or store routes.
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_merchant_from_cookie_or_header
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.modules.core.services.auth_service import auth_service
|
||||
from app.modules.tenancy.models.user_password_reset_token import (
|
||||
UserPasswordResetToken, # noqa: API-007
|
||||
)
|
||||
from app.modules.tenancy.services.user_auth_service import user_auth_service
|
||||
from models.schema.auth import (
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
@@ -115,3 +120,94 @@ def merchant_logout(response: Response):
|
||||
logger.debug("Deleted merchant_token cookie (path=/merchants)")
|
||||
|
||||
return LogoutResponse(message="Logged out successfully")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PASSWORD RESET
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class ForgotPasswordResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class ResetPasswordResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
@merchant_auth_router.post("/forgot-password", response_model=ForgotPasswordResponse)
|
||||
def merchant_forgot_password(
|
||||
request: Request,
|
||||
body: ForgotPasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Request password reset for merchant user.
|
||||
|
||||
Sends password reset email if account exists.
|
||||
Always returns success to prevent email enumeration.
|
||||
"""
|
||||
user, plaintext_token = user_auth_service.request_password_reset(db, body.email)
|
||||
|
||||
if user and plaintext_token:
|
||||
try:
|
||||
scheme = "https" if should_use_secure_cookies() else "http"
|
||||
host = request.headers.get("host", "localhost:8000")
|
||||
reset_link = f"{scheme}://{host}/merchants/reset-password?token={plaintext_token}"
|
||||
|
||||
from app.modules.messaging.services.email_service import EmailService
|
||||
|
||||
email_service = EmailService(db)
|
||||
email_service.send_template(
|
||||
template_code="merchant_password_reset",
|
||||
to_email=user.email,
|
||||
to_name=user.username,
|
||||
language="en",
|
||||
variables={
|
||||
"first_name": user.username,
|
||||
"reset_link": reset_link,
|
||||
"expiry_hours": str(UserPasswordResetToken.TOKEN_EXPIRY_HOURS),
|
||||
"platform_name": "Orion",
|
||||
},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Password reset email sent to {user.email}") # noqa: SEC021
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to send password reset email: {e}") # noqa: SEC021
|
||||
else:
|
||||
logger.info(f"Password reset requested for non-existent/inactive email {body.email}") # noqa: SEC021
|
||||
|
||||
return ForgotPasswordResponse(
|
||||
message="If an account exists with this email, a password reset link has been sent."
|
||||
)
|
||||
|
||||
|
||||
@merchant_auth_router.post("/reset-password", response_model=ResetPasswordResponse)
|
||||
def merchant_reset_password(
|
||||
body: ResetPasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Reset merchant user password using reset token.
|
||||
|
||||
Validates the token and sets the new password.
|
||||
"""
|
||||
user = user_auth_service.reset_password(db, body.token, body.new_password)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Password reset completed for user {user.id} ({user.email})") # noqa: SEC021
|
||||
|
||||
return ResetPasswordResponse(
|
||||
message="Password reset successfully. You can now log in with your new password."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user