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

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