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:
@@ -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,
|
||||
|
||||
81
app/modules/tenancy/routes/api/email_verification.py
Normal file
81
app/modules/tenancy/routes/api/email_verification.py
Normal 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."
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
110
app/modules/tenancy/routes/pages/platform.py
Normal file
110
app/modules/tenancy/routes/pages/platform.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# app/modules/tenancy/routes/pages/email_verification.py
|
||||
"""
|
||||
Email verification page route.
|
||||
|
||||
Renders HTML result pages for email verification:
|
||||
- GET /verify-email?token={token} - Verify email and show result page
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROUTE_CONFIG = {
|
||||
"prefix": "",
|
||||
}
|
||||
|
||||
# Shared HTML template for verification result
|
||||
_HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title} - Orion</title>
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<style>
|
||||
body {{ font-family: 'Inter', Arial, sans-serif; margin: 0; padding: 0; background: #f9fafb;
|
||||
display: flex; align-items: center; justify-content: center; min-height: 100vh; }}
|
||||
.card {{ background: white; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,.1);
|
||||
max-width: 480px; width: 90%; text-align: center; overflow: hidden; }}
|
||||
.header {{ padding: 30px; background: linear-gradient(135deg, {color_from} 0%, {color_to} 100%); }}
|
||||
.header h1 {{ color: white; margin: 0; font-size: 24px; }}
|
||||
.body {{ padding: 30px; }}
|
||||
.body p {{ color: #4b5563; line-height: 1.6; }}
|
||||
.icon {{ font-size: 48px; margin-bottom: 16px; }}
|
||||
.btn {{ display: inline-block; padding: 12px 24px; background: #6366f1; color: white;
|
||||
text-decoration: none; border-radius: 8px; font-weight: 600; margin-top: 16px; }}
|
||||
.btn:hover {{ background: #4f46e5; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header"><h1>{title}</h1></div>
|
||||
<div class="body">
|
||||
<div class="icon">{icon}</div>
|
||||
<p>{message}</p>
|
||||
<a href="{link_url}" class="btn">{link_text}</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/verify-email", response_class=HTMLResponse, include_in_schema=False)
|
||||
def verify_email_page(
|
||||
request: Request,
|
||||
token: str = Query(..., description="Email verification token"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Verify email address via token link from email.
|
||||
|
||||
Validates the token, marks user's email as verified, and renders
|
||||
a success or error HTML page.
|
||||
"""
|
||||
token_record = EmailVerificationToken.find_valid_token(db, token)
|
||||
|
||||
if not token_record:
|
||||
logger.warning("Invalid or expired email verification token used")
|
||||
return HTMLResponse(
|
||||
content=_HTML_TEMPLATE.format(
|
||||
title="Verification Failed",
|
||||
color_from="#ef4444",
|
||||
color_to="#dc2626",
|
||||
icon="❌",
|
||||
message="This verification link is invalid or has expired. "
|
||||
"Please request a new verification email.",
|
||||
link_url="/merchants/login",
|
||||
link_text="Go to Login",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Mark token as used and verify user's email
|
||||
user = token_record.user
|
||||
user.is_email_verified = True
|
||||
token_record.mark_used(db)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Email verified for user {user.id} ({user.email})")
|
||||
|
||||
return HTMLResponse(
|
||||
content=_HTML_TEMPLATE.format(
|
||||
title="Email Verified",
|
||||
color_from="#10b981",
|
||||
color_to="#059669",
|
||||
icon="✅",
|
||||
message="Your email address has been successfully verified! "
|
||||
"You can now log in to your account.",
|
||||
link_url="/merchants/login",
|
||||
link_text="Go to Login",
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user