feat: email verification, merchant/store password reset, seed gap fix
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

- 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:
2026-02-18 23:22:46 +01:00
parent a8b29750a5
commit d9fc52d47a
30 changed files with 2574 additions and 29 deletions

View File

@@ -6,11 +6,12 @@ Merchant management endpoints for admin.
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Body, Depends, Path, Query
from fastapi import APIRouter, Body, Depends, Path, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.modules.tenancy.exceptions import (
ConfirmationRequiredException,
MerchantHasStoresException,
@@ -34,6 +35,7 @@ logger = logging.getLogger(__name__)
@admin_merchants_router.post("", response_model=MerchantCreateResponse)
def create_merchant_with_owner(
request: Request,
merchant_data: MerchantCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
@@ -44,7 +46,8 @@ def create_merchant_with_owner(
This endpoint:
1. Creates a new merchant record
2. Creates an owner user account with owner_email (if not exists)
3. Returns credentials (temporary password shown ONCE if new user created)
3. Sends email verification + welcome email to owner
4. Returns credentials (temporary password shown ONCE if new user created)
**Email Fields:**
- `owner_email`: Used for owner's login/authentication (stored in users.email)
@@ -58,6 +61,38 @@ def create_merchant_with_owner(
db.commit() # ✅ ARCH: Commit at API level for transaction control
# Send verification email to new owner (only for newly created users)
if temp_password:
try:
from app.modules.messaging.services.email_service import EmailService
from app.modules.tenancy.models import EmailVerificationToken
plaintext_token = EmailVerificationToken.create_for_user(db, owner_user.id)
scheme = "https" if should_use_secure_cookies() else "http"
host = request.headers.get("host", "localhost:8000")
verification_link = f"{scheme}://{host}/verify-email?token={plaintext_token}"
email_service = EmailService(db)
email_service.send_template(
template_code="email_verification",
to_email=owner_user.email,
to_name=owner_user.username,
language="en",
variables={
"first_name": owner_user.username,
"verification_link": verification_link,
"expiry_hours": str(EmailVerificationToken.TOKEN_EXPIRY_HOURS),
"platform_name": "Orion",
},
)
db.commit()
logger.info(f"Verification email sent to {owner_user.email}")
except Exception as e:
db.rollback()
logger.error(f"Failed to send verification email: {e}") # noqa: SEC021
return MerchantCreateResponse(
merchant=MerchantResponse(
id=merchant.id,

View File

@@ -0,0 +1,81 @@
# app/modules/tenancy/routes/api/email_verification.py
"""
Email verification API endpoints.
Public endpoints (no auth required):
- POST /resend-verification - Resend verification email
"""
import logging
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.modules.tenancy.models.email_verification_token import (
EmailVerificationToken, # noqa: API-007
)
from app.modules.tenancy.services.user_auth_service import user_auth_service
email_verification_api_router = APIRouter()
logger = logging.getLogger(__name__)
class ResendVerificationRequest(BaseModel):
email: str
class ResendVerificationResponse(BaseModel):
message: str
@email_verification_api_router.post(
"/resend-verification", response_model=ResendVerificationResponse
)
def resend_verification(
request: Request,
body: ResendVerificationRequest,
db: Session = Depends(get_db),
):
"""
Resend email verification link.
Always returns success to prevent email enumeration.
"""
user, plaintext_token = user_auth_service.request_verification_resend(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")
verification_link = f"{scheme}://{host}/verify-email?token={plaintext_token}"
from app.modules.messaging.services.email_service import EmailService
email_service = EmailService(db)
email_service.send_template(
template_code="email_verification",
to_email=user.email,
to_name=user.username,
language="en",
variables={
"first_name": user.username,
"verification_link": verification_link,
"expiry_hours": str(EmailVerificationToken.TOKEN_EXPIRY_HOURS),
"platform_name": "Orion",
},
)
db.commit()
logger.info(f"Verification email resent to {user.email}")
except Exception as e:
db.rollback()
logger.error(f"Failed to resend verification email: {e}") # noqa: SEC021
else:
logger.info(f"Resend verification requested for {body.email} (not found or already verified)")
return ResendVerificationResponse(
message="If an account exists with this email and is not yet verified, a verification link has been sent."
)

View File

@@ -24,15 +24,19 @@ from app.modules.tenancy.schemas import (
from app.modules.tenancy.services.merchant_service import merchant_service
from models.schema.auth import UserContext
from .email_verification import email_verification_api_router
from .merchant_auth import merchant_auth_router
logger = logging.getLogger(__name__)
router = APIRouter()
# Include auth routes (/auth/login, /auth/logout, /auth/me)
# Include auth routes (/auth/login, /auth/logout, /auth/me, /auth/forgot-password, /auth/reset-password)
router.include_router(merchant_auth_router, tags=["merchant-auth"])
# Include email verification routes (/resend-verification)
router.include_router(email_verification_api_router, tags=["email-verification"])
# Account routes are defined below with /account prefix
_account_router = APIRouter(prefix="/account")

View File

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

View File

@@ -23,6 +23,10 @@ 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.exceptions import InvalidCredentialsException
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 middleware.store_context import get_current_store
from models.schema.auth import LogoutResponse, StoreUserResponse, UserContext, UserLogin
@@ -193,3 +197,100 @@ def get_current_store_user(
role=user.role,
is_active=user.is_active,
)
# ============================================================================
# PASSWORD RESET
# ============================================================================
class StoreForgotPasswordRequest(BaseModel):
email: str
class StoreForgotPasswordResponse(BaseModel):
message: str
class StoreResetPasswordRequest(BaseModel):
token: str
new_password: str
class StoreResetPasswordResponse(BaseModel):
message: str
@store_auth_router.post("/forgot-password", response_model=StoreForgotPasswordResponse)
def store_forgot_password(
request: Request,
body: StoreForgotPasswordRequest,
db: Session = Depends(get_db),
):
"""
Request password reset for store team 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")
# Include store code in reset link if available
store = get_current_store(request)
if store:
reset_link = f"{scheme}://{host}/store/{store.store_code}/reset-password?token={plaintext_token}"
else:
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",
},
store_id=store.id if store else None,
)
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 StoreForgotPasswordResponse(
message="If an account exists with this email, a password reset link has been sent."
)
@store_auth_router.post("/reset-password", response_model=StoreResetPasswordResponse)
def store_reset_password(
body: StoreResetPasswordRequest,
db: Session = Depends(get_db),
):
"""
Reset store team 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 StoreResetPasswordResponse(
message="Password reset successfully. You can now log in with your new password."
)