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

@@ -173,6 +173,12 @@ db-reset:
$(PYTHON) -m alembic upgrade head
@echo "Initializing production data..."
$(PYTHON) scripts/seed/init_production.py
@echo "Initializing log settings..."
$(PYTHON) scripts/seed/init_log_settings.py
@echo "Creating default CMS content pages..."
$(PYTHON) scripts/seed/create_default_content_pages.py
@echo "Seeding email templates..."
$(PYTHON) scripts/seed/seed_email_templates.py
@echo "Seeding demo data..."
ifeq ($(DETECTED_OS),Windows)
@set SEED_MODE=reset&& set FORCE_RESET=true&& $(PYTHON) scripts/seed/seed_demo.py

View File

@@ -67,6 +67,7 @@ def store_full_setup(db, store_platform):
hashed_password=auth.hash_password("storepass123"),
role="store",
is_active=True,
is_email_verified=True,
)
db.add(owner)
db.commit()

View File

@@ -18,6 +18,7 @@ from typing import Any
from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import (
EmailNotVerifiedException,
InvalidCredentialsException,
UserNotActiveException,
)
@@ -59,6 +60,9 @@ class AuthService:
if not user.is_active:
raise UserNotActiveException("User account is not active")
if not user.is_email_verified:
raise EmailNotVerifiedException()
# Update last_login timestamp
user.last_login = datetime.now(UTC)
db.commit() # SVC-006 - Login must persist last_login timestamp
@@ -159,6 +163,9 @@ class AuthService:
if not user.is_active:
raise UserNotActiveException("User account is not active")
if not user.is_email_verified:
raise EmailNotVerifiedException()
# Verify user owns at least one active merchant
merchant_count = (
db.query(Merchant)

View File

@@ -133,6 +133,35 @@ function merchantLogin() {
}
},
// Forgot password state
showForgotPassword: false,
forgotPasswordEmail: '',
forgotPasswordLoading: false,
async handleForgotPassword() {
loginLog.info('=== FORGOT PASSWORD ATTEMPT ===');
if (!this.forgotPasswordEmail.trim()) {
this.error = 'Email is required';
return;
}
this.forgotPasswordLoading = true;
this.clearErrors();
try {
await apiClient.post('/merchants/auth/forgot-password', {
email: this.forgotPasswordEmail.trim()
});
this.success = 'If an account exists with this email, a password reset link has been sent.';
this.forgotPasswordEmail = '';
} catch (error) {
window.LogConfig.logError(error, 'ForgotPassword');
this.error = error.message || 'Failed to send reset email. Please try again.';
} finally {
this.forgotPasswordLoading = false;
}
},
toggleDarkMode() {
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');

View File

@@ -92,6 +92,16 @@ class AdminRequiredException(AuthorizationException):
)
class EmailNotVerifiedException(AuthorizationException):
"""User's email is not verified."""
def __init__(self, message: str = "Email address not verified. Please check your inbox."):
super().__init__(
message=message,
error_code="EMAIL_NOT_VERIFIED",
)
class UserAlreadyExistsException(ConflictException):
"""Raised when trying to register with existing username/email."""
@@ -1072,6 +1082,7 @@ __all__ = [
"InvalidTokenException",
"InsufficientPermissionsException",
"UserNotActiveException",
"EmailNotVerifiedException",
"AdminRequiredException",
"UserAlreadyExistsException",
# Platform

View File

@@ -0,0 +1,53 @@
"""tenancy: add email_verification_tokens and user_password_reset_tokens tables
Revision ID: tenancy_002
Revises: tenancy_001
Create Date: 2026-02-18
"""
import sqlalchemy as sa
from alembic import op
revision = "tenancy_002"
down_revision = "a44f4956cfb1"
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- email_verification_tokens ---
op.create_table(
"email_verification_tokens",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_hash", sa.String(64), nullable=False, index=True),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
# --- user_password_reset_tokens ---
op.create_table(
"user_password_reset_tokens",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_hash", sa.String(64), nullable=False, index=True),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
op.drop_table("user_password_reset_tokens")
op.drop_table("email_verification_tokens")

View File

@@ -28,6 +28,7 @@ from app.modules.tenancy.models.admin import (
PlatformAlert,
)
from app.modules.tenancy.models.admin_platform import AdminPlatform
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
from app.modules.tenancy.models.merchant import Merchant
from app.modules.tenancy.models.merchant_domain import MerchantDomain
from app.modules.tenancy.models.platform import Platform
@@ -36,6 +37,7 @@ from app.modules.tenancy.models.store import Role, Store, StoreUser, StoreUserTy
from app.modules.tenancy.models.store_domain import StoreDomain
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.models.user import User, UserRole
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
__all__ = [
# Admin models
@@ -54,6 +56,9 @@ __all__ = [
# User
"User",
"UserRole",
# Tokens
"EmailVerificationToken",
"UserPasswordResetToken",
# Store
"Store",
"StoreUser",

View File

@@ -0,0 +1,91 @@
# app/modules/tenancy/models/email_verification_token.py
"""
Email verification token model for user accounts.
Security features:
- Tokens are stored as SHA256 hashes, not plaintext
- Tokens expire after 24 hours
- Only one active token per user (old tokens invalidated on new request)
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Session, relationship
from app.core.database import Base
class EmailVerificationToken(Base):
"""Email verification token for user accounts."""
__tablename__ = "email_verification_tokens"
# Token expiry in hours
TOKEN_EXPIRY_HOURS = 24
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash = Column(String(64), nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User")
def __repr__(self):
return f"<EmailVerificationToken(id={self.id}, user_id={self.user_id}, expires_at={self.expires_at})>"
@staticmethod
def hash_token(token: str) -> str:
"""Hash a token using SHA256."""
return hashlib.sha256(token.encode()).hexdigest()
@classmethod
def create_for_user(cls, db: Session, user_id: int) -> str:
"""Create a new email verification token for a user.
Invalidates any existing tokens for the user.
Returns the plaintext token (to be sent via email).
"""
# Invalidate existing tokens for this user
db.query(cls).filter(
cls.user_id == user_id,
cls.used_at.is_(None),
).delete()
# Generate new token
plaintext_token = secrets.token_urlsafe(32)
token_hash = cls.hash_token(plaintext_token)
# Create token record
token = cls(
user_id=user_id,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=cls.TOKEN_EXPIRY_HOURS),
)
db.add(token)
db.flush()
return plaintext_token
@classmethod
def find_valid_token(cls, db: Session, plaintext_token: str) -> "EmailVerificationToken | None":
"""Find a valid (not expired, not used) token."""
token_hash = cls.hash_token(plaintext_token)
return db.query(cls).filter(
cls.token_hash == token_hash,
cls.expires_at > datetime.utcnow(),
cls.used_at.is_(None),
).first()
def mark_used(self, db: Session) -> None:
"""Mark this token as used."""
self.used_at = datetime.utcnow()
db.flush()

View File

@@ -0,0 +1,91 @@
# app/modules/tenancy/models/user_password_reset_token.py
"""
Password reset token model for user accounts (merchants/store users).
Security features:
- Tokens are stored as SHA256 hashes, not plaintext
- Tokens expire after 1 hour
- Only one active token per user (old tokens invalidated on new request)
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Session, relationship
from app.core.database import Base
class UserPasswordResetToken(Base):
"""Password reset token for user accounts (merchants/store team)."""
__tablename__ = "user_password_reset_tokens"
# Token expiry in hours
TOKEN_EXPIRY_HOURS = 1
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash = Column(String(64), nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User")
def __repr__(self):
return f"<UserPasswordResetToken(id={self.id}, user_id={self.user_id}, expires_at={self.expires_at})>"
@staticmethod
def hash_token(token: str) -> str:
"""Hash a token using SHA256."""
return hashlib.sha256(token.encode()).hexdigest()
@classmethod
def create_for_user(cls, db: Session, user_id: int) -> str:
"""Create a new password reset token for a user.
Invalidates any existing tokens for the user.
Returns the plaintext token (to be sent via email).
"""
# Invalidate existing tokens for this user
db.query(cls).filter(
cls.user_id == user_id,
cls.used_at.is_(None),
).delete()
# Generate new token
plaintext_token = secrets.token_urlsafe(32)
token_hash = cls.hash_token(plaintext_token)
# Create token record
token = cls(
user_id=user_id,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=cls.TOKEN_EXPIRY_HOURS),
)
db.add(token)
db.flush()
return plaintext_token
@classmethod
def find_valid_token(cls, db: Session, plaintext_token: str) -> "UserPasswordResetToken | None":
"""Find a valid (not expired, not used) token."""
token_hash = cls.hash_token(plaintext_token)
return db.query(cls).filter(
cls.token_hash == token_hash,
cls.expires_at > datetime.utcnow(),
cls.used_at.is_(None),
).first()
def mark_used(self, db: Session) -> None:
"""Mark this token as used."""
self.used_at = datetime.utcnow()
db.flush()

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

View 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="&#10060;",
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="&#9989;",
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",
)
)

View File

@@ -71,7 +71,7 @@ class MerchantService:
hashed_password=auth_manager.hash_password(temp_password),
role="user",
is_active=True,
is_email_verified=True,
is_email_verified=False,
)
db.add(owner_user)
db.flush() # Get owner_user.id

View File

@@ -0,0 +1,91 @@
# app/modules/tenancy/services/user_auth_service.py
"""
Service for user-level auth operations: password reset, email verification.
Handles:
- Password reset token creation, validation, and password update
- Email verification token creation and resend logic
"""
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import InvalidTokenException
from app.modules.tenancy.models import User
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
from middleware.auth import AuthManager
logger = logging.getLogger(__name__)
class UserAuthService:
"""Service for user password reset and email verification."""
def __init__(self):
self.auth_manager = AuthManager()
def request_password_reset(self, db: Session, email: str) -> tuple[User | None, str | None]:
"""
Create a password reset token for a user by email.
Returns (user, plaintext_token) if user exists and is active,
otherwise (None, None).
"""
user = db.execute(
select(User).where(User.email == email)
).scalar_one_or_none()
if not user or not user.is_active:
return None, None
plaintext_token = UserPasswordResetToken.create_for_user(db, user.id)
return user, plaintext_token
def reset_password(self, db: Session, token: str, new_password: str) -> User:
"""
Validate reset token and set new password.
Args:
db: Database session
token: Plaintext reset token
new_password: New password to set
Returns:
The user whose password was reset
Raises:
InvalidTokenException: If token is invalid or expired
"""
token_record = UserPasswordResetToken.find_valid_token(db, token)
if not token_record:
raise InvalidTokenException("Invalid or expired password reset token")
user = token_record.user
user.hashed_password = self.auth_manager.hash_password(new_password)
token_record.mark_used(db)
return user
def request_verification_resend(self, db: Session, email: str) -> tuple[User | None, str | None]:
"""
Create a new email verification token for an unverified user.
Returns (user, plaintext_token) if user exists and is not yet verified,
otherwise (None, None).
"""
user = db.execute(
select(User).where(User.email == email)
).scalar_one_or_none()
if not user or user.is_email_verified:
return None, None
plaintext_token = EmailVerificationToken.create_for_user(db, user.id)
return user, plaintext_token
user_auth_service = UserAuthService()

View File

@@ -158,6 +158,35 @@ function storeLogin() {
}
},
// Forgot password state
showForgotPassword: false,
forgotPasswordEmail: '',
forgotPasswordLoading: false,
async handleForgotPassword() {
storeLoginLog.info('=== FORGOT PASSWORD ATTEMPT ===');
if (!this.forgotPasswordEmail.trim()) {
this.error = 'Email is required';
return;
}
this.forgotPasswordLoading = true;
this.clearErrors();
try {
await apiClient.post('/store/auth/forgot-password', {
email: this.forgotPasswordEmail.trim()
});
this.success = 'If an account exists with this email, a password reset link has been sent.';
this.forgotPasswordEmail = '';
} catch (error) {
window.LogConfig.logError(error, 'ForgotPassword');
this.error = error.message || 'Failed to send reset email. Please try again.';
} finally {
this.forgotPasswordLoading = false;
}
},
clearErrors() {
storeLoginLog.debug('Clearing form errors');
this.error = '';

View File

@@ -86,18 +86,48 @@
<hr class="my-8" />
<p class="mt-4">
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
href="#">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
&larr; Back to Platform
</a>
</p>
<!-- Forgot Password Form -->
<div x-show="showForgotPassword" x-transition>
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">Enter your email address and we'll send you a link to reset your password.</p>
<form @submit.prevent="handleForgotPassword">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email</span>
<input x-model="forgotPasswordEmail"
:disabled="forgotPasswordLoading"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
placeholder="you@example.com"
type="email"
required />
</label>
<button type="submit" :disabled="forgotPasswordLoading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
<span x-show="!forgotPasswordLoading">Send Reset Link</span>
<span x-show="forgotPasswordLoading">Sending...</span>
</button>
</form>
<p class="mt-4">
<a @click.prevent="showForgotPassword = false"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
&larr; Back to Login
</a>
</p>
</div>
<div x-show="!showForgotPassword">
<p class="mt-4">
<a @click.prevent="showForgotPassword = true"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
&larr; Back to Platform
</a>
</p>
</div>
</div>
</div>
</div>

View File

@@ -119,18 +119,48 @@
<hr class="my-8" />
<p class="mt-4">
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
href="#">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
← Back to Platform
</a>
</p>
<!-- Forgot Password Form -->
<div x-show="showForgotPassword" x-transition>
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">Enter your email address and we'll send you a link to reset your password.</p>
<form @submit.prevent="handleForgotPassword">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email</span>
<input x-model="forgotPasswordEmail"
:disabled="forgotPasswordLoading"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
placeholder="you@example.com"
type="email"
required />
</label>
<button type="submit" :disabled="forgotPasswordLoading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
<span x-show="!forgotPasswordLoading">Send Reset Link</span>
<span x-show="forgotPasswordLoading">Sending...</span>
</button>
</form>
<p class="mt-4">
<a @click.prevent="showForgotPassword = false"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
&larr; Back to Login
</a>
</p>
</div>
<div x-show="!showForgotPassword">
<p class="mt-4">
<a @click.prevent="showForgotPassword = true"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
&larr; Back to Platform
</a>
</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,255 @@
# app/modules/tenancy/tests/integration/test_email_verification_routes.py
"""
Integration tests for email verification routes.
Tests:
- GET /verify-email?token={token} (page route)
- POST /api/v1/merchants/resend-verification (API route)
- Login blocked for unverified email
"""
import uuid
import pytest
from app.modules.tenancy.models import Merchant, User
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
# ============================================================================
# Fixtures
# ============================================================================
MERCHANT_AUTH_BASE = "/api/v1/merchants/auth"
RESEND_BASE = "/api/v1/merchants/resend-verification"
@pytest.fixture
def ev_unverified_user(db):
"""Create an unverified user for email verification tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"unverified_{uid}@test.com",
username=f"unverified_{uid}",
hashed_password=auth.hash_password("testpass123"), # noqa: SEC001
role="store",
is_active=True,
is_email_verified=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def ev_verified_user(db):
"""Create a verified user for comparison tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"verified_{uid}@test.com",
username=f"verified_{uid}",
hashed_password=auth.hash_password("testpass123"), # noqa: SEC001
role="store",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def ev_merchant(db, ev_verified_user):
"""Create a merchant for the verified user."""
merchant = Merchant(
name="Verification Test Merchant",
owner_user_id=ev_verified_user.id,
contact_email=ev_verified_user.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def ev_unverified_merchant(db, ev_unverified_user):
"""Create a merchant for the unverified user."""
merchant = Merchant(
name="Unverified Owner Merchant",
owner_user_id=ev_unverified_user.id,
contact_email=ev_unverified_user.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
# ============================================================================
# GET /verify-email Tests
# ============================================================================
class TestVerifyEmailPage:
"""Tests for GET /verify-email?token={token}."""
def test_verify_email_success(self, client, db, ev_unverified_user):
"""Valid token verifies the user's email."""
plaintext = EmailVerificationToken.create_for_user(db, ev_unverified_user.id)
db.commit()
response = client.get(f"/verify-email?token={plaintext}")
assert response.status_code == 200
assert "Email Verified" in response.text
# User should now be verified
db.refresh(ev_unverified_user)
assert ev_unverified_user.is_email_verified is True
def test_verify_email_invalid_token(self, client):
"""Invalid token shows error page."""
response = client.get("/verify-email?token=invalid-token-value")
assert response.status_code == 400
assert "Verification Failed" in response.text
def test_verify_email_expired_token(self, client, db, ev_unverified_user):
"""Expired token shows error page."""
from datetime import datetime, timedelta
plaintext = EmailVerificationToken.create_for_user(db, ev_unverified_user.id)
db.commit()
# Manually expire the token
token = EmailVerificationToken.find_valid_token(db, plaintext)
token.expires_at = datetime.utcnow() - timedelta(hours=1)
db.commit()
response = client.get(f"/verify-email?token={plaintext}")
assert response.status_code == 400
assert "Verification Failed" in response.text
def test_verify_email_used_token(self, client, db, ev_unverified_user):
"""Already-used token shows error page."""
plaintext = EmailVerificationToken.create_for_user(db, ev_unverified_user.id)
db.commit()
# Use the token first
token = EmailVerificationToken.find_valid_token(db, plaintext)
token.mark_used(db)
db.commit()
response = client.get(f"/verify-email?token={plaintext}")
assert response.status_code == 400
assert "Verification Failed" in response.text
def test_verify_email_missing_token_param(self, client):
"""Missing token parameter returns 422."""
response = client.get("/verify-email")
assert response.status_code == 422
# ============================================================================
# POST /resend-verification Tests
# ============================================================================
class TestResendVerification:
"""Tests for POST /api/v1/merchants/resend-verification."""
def test_resend_always_returns_success(self, client, ev_unverified_user):
"""Endpoint always returns success (anti-enumeration)."""
response = client.post(
RESEND_BASE,
json={"email": ev_unverified_user.email},
)
assert response.status_code == 200
data = response.json()
assert "message" in data
assert "verification link has been sent" in data["message"]
def test_resend_nonexistent_email(self, client):
"""Non-existent email still returns success (anti-enumeration)."""
response = client.post(
RESEND_BASE,
json={"email": "nonexistent@test.com"},
)
assert response.status_code == 200
data = response.json()
assert "message" in data
def test_resend_already_verified_email(self, client, ev_verified_user):
"""Already-verified email returns success but doesn't send."""
response = client.post(
RESEND_BASE,
json={"email": ev_verified_user.email},
)
assert response.status_code == 200
data = response.json()
assert "message" in data
# ============================================================================
# Login Blocked for Unverified Email Tests
# ============================================================================
class TestLoginBlockedUnverifiedEmail:
"""Tests that login is blocked for users with unverified email."""
def test_merchant_login_blocked_unverified(self, client, ev_unverified_user, ev_unverified_merchant):
"""Merchant login should fail with EMAIL_NOT_VERIFIED for unverified user."""
response = client.post(
f"{MERCHANT_AUTH_BASE}/login",
json={
"email_or_username": ev_unverified_user.username,
"password": "testpass123", # noqa: SEC001
},
)
assert response.status_code == 403
data = response.json()
assert data.get("error_code") == "EMAIL_NOT_VERIFIED"
def test_merchant_login_allowed_verified(self, client, ev_verified_user, ev_merchant):
"""Merchant login should succeed for verified user."""
response = client.post(
f"{MERCHANT_AUTH_BASE}/login",
json={
"email_or_username": ev_verified_user.username,
"password": "testpass123", # noqa: SEC001
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
def test_verify_then_login(self, client, db, ev_unverified_user, ev_unverified_merchant):
"""After verifying email, login should succeed."""
# Create verification token and verify
plaintext = EmailVerificationToken.create_for_user(db, ev_unverified_user.id)
db.commit()
verify_response = client.get(f"/verify-email?token={plaintext}")
assert verify_response.status_code == 200
# Now login should work
login_response = client.post(
f"{MERCHANT_AUTH_BASE}/login",
json={
"email_or_username": ev_unverified_user.username,
"password": "testpass123", # noqa: SEC001
},
)
assert login_response.status_code == 200
assert "access_token" in login_response.json()

View File

@@ -35,6 +35,7 @@ def ma_owner(db):
hashed_password=auth.hash_password("mapass123"),
role="store",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()
@@ -71,6 +72,7 @@ def ma_non_merchant_user(db):
hashed_password=auth.hash_password("nonmerch123"),
role="store",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()

View File

@@ -0,0 +1,231 @@
# app/modules/tenancy/tests/integration/test_merchant_password_reset.py
"""
Integration tests for merchant password reset flow.
Tests:
- POST /api/v1/merchants/auth/forgot-password
- POST /api/v1/merchants/auth/reset-password
"""
import uuid
import pytest
from app.modules.tenancy.models import Merchant, User
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/merchants/auth"
@pytest.fixture
def mpr_user(db):
"""Create a merchant owner user for password reset tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"mpr_{uid}@test.com",
username=f"mpr_{uid}",
hashed_password=auth.hash_password("oldpass123"), # noqa: SEC001
role="store",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def mpr_merchant(db, mpr_user):
"""Create a merchant for the test user."""
merchant = Merchant(
name="Password Reset Test Merchant",
owner_user_id=mpr_user.id,
contact_email=mpr_user.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def mpr_inactive_user(db):
"""Create an inactive user."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"mpri_{uid}@test.com",
username=f"mpri_{uid}",
hashed_password=auth.hash_password("inactive123"), # noqa: SEC001
role="store",
is_active=False,
is_email_verified=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
# ============================================================================
# Forgot Password Tests
# ============================================================================
class TestMerchantForgotPassword:
"""Tests for POST /api/v1/merchants/auth/forgot-password."""
def test_forgot_password_success(self, client, mpr_user):
"""Valid email returns success message."""
response = client.post(
f"{BASE}/forgot-password",
json={"email": mpr_user.email},
)
assert response.status_code == 200
data = response.json()
assert "message" in data
assert "password reset link" in data["message"]
def test_forgot_password_nonexistent_email(self, client):
"""Non-existent email still returns success (anti-enumeration)."""
response = client.post(
f"{BASE}/forgot-password",
json={"email": "doesnotexist@test.com"},
)
assert response.status_code == 200
data = response.json()
assert "message" in data
def test_forgot_password_inactive_user(self, client, mpr_inactive_user):
"""Inactive user email still returns success (anti-enumeration)."""
response = client.post(
f"{BASE}/forgot-password",
json={"email": mpr_inactive_user.email},
)
assert response.status_code == 200
data = response.json()
assert "message" in data
# ============================================================================
# Reset Password Tests
# ============================================================================
class TestMerchantResetPassword:
"""Tests for POST /api/v1/merchants/auth/reset-password."""
def test_reset_password_success(self, client, db, mpr_user, mpr_merchant):
"""Valid token resets password and allows login with new password."""
plaintext = UserPasswordResetToken.create_for_user(db, mpr_user.id)
db.commit()
response = client.post(
f"{BASE}/reset-password",
json={
"token": plaintext,
"new_password": "newpass456", # noqa: SEC001
},
)
assert response.status_code == 200
data = response.json()
assert "Password reset successfully" in data["message"]
# Verify can login with new password
login_resp = client.post(
f"{BASE}/login",
json={
"email_or_username": mpr_user.username,
"password": "newpass456", # noqa: SEC001
},
)
assert login_resp.status_code == 200
assert "access_token" in login_resp.json()
def test_reset_password_old_password_fails(self, client, db, mpr_user, mpr_merchant):
"""After reset, old password should not work."""
plaintext = UserPasswordResetToken.create_for_user(db, mpr_user.id)
db.commit()
client.post(
f"{BASE}/reset-password",
json={
"token": plaintext,
"new_password": "newpass456", # noqa: SEC001
},
)
# Old password should fail
login_resp = client.post(
f"{BASE}/login",
json={
"email_or_username": mpr_user.username,
"password": "oldpass123", # noqa: SEC001
},
)
assert login_resp.status_code == 401
def test_reset_password_invalid_token(self, client):
"""Invalid token returns error."""
response = client.post(
f"{BASE}/reset-password",
json={
"token": "invalid-token-value",
"new_password": "newpass456", # noqa: SEC001
},
)
assert response.status_code == 401
data = response.json()
assert data.get("error_code") == "INVALID_TOKEN"
def test_reset_password_expired_token(self, client, db, mpr_user):
"""Expired token returns error."""
from datetime import datetime, timedelta
plaintext = UserPasswordResetToken.create_for_user(db, mpr_user.id)
db.commit()
# Manually expire the token
token = UserPasswordResetToken.find_valid_token(db, plaintext)
token.expires_at = datetime.utcnow() - timedelta(hours=1)
db.commit()
response = client.post(
f"{BASE}/reset-password",
json={
"token": plaintext,
"new_password": "newpass456", # noqa: SEC001
},
)
assert response.status_code == 401
def test_reset_password_used_token(self, client, db, mpr_user):
"""Already-used token returns error."""
plaintext = UserPasswordResetToken.create_for_user(db, mpr_user.id)
db.commit()
# Use the token
token = UserPasswordResetToken.find_valid_token(db, plaintext)
token.mark_used(db)
db.commit()
response = client.post(
f"{BASE}/reset-password",
json={
"token": plaintext,
"new_password": "newpass456", # noqa: SEC001
},
)
assert response.status_code == 401

View File

@@ -0,0 +1,212 @@
# app/modules/tenancy/tests/integration/test_store_password_reset.py
"""
Integration tests for store user password reset flow.
Tests:
- POST /api/v1/store/auth/forgot-password
- POST /api/v1/store/auth/reset-password
"""
import uuid
import pytest
from app.modules.tenancy.models import Merchant, Store, StoreUser, User
from app.modules.tenancy.models.store import StoreUserType
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/store/auth"
@pytest.fixture
def spr_owner(db):
"""Create a store owner user for password reset tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"spr_{uid}@test.com",
username=f"spr_{uid}",
hashed_password=auth.hash_password("storepass123"), # noqa: SEC001
role="store",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def spr_merchant(db, spr_owner):
"""Create a merchant for the store owner."""
merchant = Merchant(
name="Store PW Reset Test Merchant",
owner_user_id=spr_owner.id,
contact_email=spr_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def spr_store(db, spr_merchant):
"""Create a store for password reset tests."""
uid = uuid.uuid4().hex[:6].upper()
store = Store(
merchant_id=spr_merchant.id,
store_code=f"SPR{uid}",
subdomain=f"spr-{uid.lower()}",
name="Store PW Reset Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def spr_store_user(db, spr_owner, spr_store):
"""Associate the owner with the store."""
store_user = StoreUser(
user_id=spr_owner.id,
store_id=spr_store.id,
user_type=StoreUserType.OWNER.value,
is_active=True,
)
db.add(store_user)
db.commit()
db.refresh(store_user)
return store_user
# ============================================================================
# Forgot Password Tests
# ============================================================================
class TestStoreForgotPassword:
"""Tests for POST /api/v1/store/auth/forgot-password."""
def test_forgot_password_success(self, client, spr_owner):
"""Valid email returns success message."""
response = client.post(
f"{BASE}/forgot-password",
json={"email": spr_owner.email},
)
assert response.status_code == 200
data = response.json()
assert "message" in data
assert "password reset link" in data["message"]
def test_forgot_password_nonexistent_email(self, client):
"""Non-existent email still returns success (anti-enumeration)."""
response = client.post(
f"{BASE}/forgot-password",
json={"email": "nonexistent_store@test.com"},
)
assert response.status_code == 200
data = response.json()
assert "message" in data
# ============================================================================
# Reset Password Tests
# ============================================================================
class TestStoreResetPassword:
"""Tests for POST /api/v1/store/auth/reset-password."""
def test_reset_password_success(self, client, db, spr_owner, spr_store, spr_store_user):
"""Valid token resets password and allows login with new password."""
plaintext = UserPasswordResetToken.create_for_user(db, spr_owner.id)
db.commit()
response = client.post(
f"{BASE}/reset-password",
json={
"token": plaintext,
"new_password": "newstorepass456", # noqa: SEC001
},
)
assert response.status_code == 200
data = response.json()
assert "Password reset successfully" in data["message"]
# Verify can login with new password
login_resp = client.post(
f"{BASE}/login",
json={
"email_or_username": spr_owner.username,
"password": "newstorepass456", # noqa: SEC001
"store_code": spr_store.store_code,
},
)
assert login_resp.status_code == 200
assert "access_token" in login_resp.json()
def test_reset_password_invalid_token(self, client):
"""Invalid token returns error."""
response = client.post(
f"{BASE}/reset-password",
json={
"token": "invalid-token-value",
"new_password": "newstorepass456", # noqa: SEC001
},
)
assert response.status_code == 401
data = response.json()
assert data.get("error_code") == "INVALID_TOKEN"
def test_reset_password_expired_token(self, client, db, spr_owner):
"""Expired token returns error."""
from datetime import datetime, timedelta
plaintext = UserPasswordResetToken.create_for_user(db, spr_owner.id)
db.commit()
# Manually expire the token
token = UserPasswordResetToken.find_valid_token(db, plaintext)
token.expires_at = datetime.utcnow() - timedelta(hours=1)
db.commit()
response = client.post(
f"{BASE}/reset-password",
json={
"token": plaintext,
"new_password": "newstorepass456", # noqa: SEC001
},
)
assert response.status_code == 401
def test_reset_password_used_token(self, client, db, spr_owner):
"""Already-used token returns error."""
plaintext = UserPasswordResetToken.create_for_user(db, spr_owner.id)
db.commit()
# Use the token
token = UserPasswordResetToken.find_valid_token(db, plaintext)
token.mark_used(db)
db.commit()
response = client.post(
f"{BASE}/reset-password",
json={
"token": plaintext,
"new_password": "newstorepass456", # noqa: SEC001
},
)
assert response.status_code == 401

View File

@@ -0,0 +1,142 @@
# app/modules/tenancy/tests/unit/test_email_verification_token.py
"""Unit tests for EmailVerificationToken model."""
import uuid
from datetime import datetime, timedelta
import pytest
from app.modules.tenancy.models import User
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
@pytest.mark.unit
@pytest.mark.database
class TestEmailVerificationToken:
"""Test EmailVerificationToken model CRUD operations."""
@pytest.fixture
def evk_user(self, db):
"""Create a test user for token tests."""
uid = uuid.uuid4().hex[:8]
user = User(
email=f"evk_{uid}@test.com",
username=f"evk_{uid}",
hashed_password="hashed_password", # noqa: SEC001
role="store",
is_active=True,
is_email_verified=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def test_create_for_user(self, db, evk_user):
"""Test creating a verification token returns a plaintext token."""
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
db.commit()
assert plaintext is not None
assert len(plaintext) > 20 # secrets.token_urlsafe(32) produces ~43 chars
def test_find_valid_token(self, db, evk_user):
"""Test finding a valid token by plaintext."""
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
db.commit()
found = EmailVerificationToken.find_valid_token(db, plaintext)
assert found is not None
assert found.user_id == evk_user.id
assert found.used_at is None
def test_find_valid_token_wrong_token(self, db, evk_user):
"""Test that a wrong token returns None."""
EmailVerificationToken.create_for_user(db, evk_user.id)
db.commit()
found = EmailVerificationToken.find_valid_token(db, "wrong-token-value")
assert found is None
def test_mark_used(self, db, evk_user):
"""Test marking a token as used."""
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
db.commit()
token = EmailVerificationToken.find_valid_token(db, plaintext)
assert token is not None
token.mark_used(db)
db.commit()
assert token.used_at is not None
# Token should no longer be findable
found = EmailVerificationToken.find_valid_token(db, plaintext)
assert found is None
def test_expired_token_not_found(self, db, evk_user):
"""Test that an expired token is not returned by find_valid_token."""
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
db.commit()
# Manually expire the token
token = EmailVerificationToken.find_valid_token(db, plaintext)
token.expires_at = datetime.utcnow() - timedelta(hours=1)
db.commit()
found = EmailVerificationToken.find_valid_token(db, plaintext)
assert found is None
def test_create_invalidates_old_tokens(self, db, evk_user):
"""Test that creating a new token invalidates old unused tokens."""
plaintext_1 = EmailVerificationToken.create_for_user(db, evk_user.id)
db.commit()
plaintext_2 = EmailVerificationToken.create_for_user(db, evk_user.id)
db.commit()
# Old token should be gone (deleted)
found_1 = EmailVerificationToken.find_valid_token(db, plaintext_1)
assert found_1 is None
# New token should work
found_2 = EmailVerificationToken.find_valid_token(db, plaintext_2)
assert found_2 is not None
def test_hash_token_deterministic(self):
"""Test that hash_token produces consistent results."""
token = "test-token-value"
hash1 = EmailVerificationToken.hash_token(token)
hash2 = EmailVerificationToken.hash_token(token)
assert hash1 == hash2
assert len(hash1) == 64 # SHA256 hex digest
def test_hash_token_different_inputs(self):
"""Test that different inputs produce different hashes."""
hash1 = EmailVerificationToken.hash_token("token-a")
hash2 = EmailVerificationToken.hash_token("token-b")
assert hash1 != hash2
def test_token_expiry_is_24_hours(self, db, evk_user):
"""Test that tokens are created with 24-hour expiry."""
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
db.commit()
token = EmailVerificationToken.find_valid_token(db, plaintext)
assert token is not None
# Expiry should be approximately 24 hours from now
expected = datetime.utcnow() + timedelta(hours=24)
delta = abs((token.expires_at - expected).total_seconds())
assert delta < 60 # Within 60 seconds tolerance
def test_token_repr(self, db, evk_user):
"""Test token __repr__."""
plaintext = EmailVerificationToken.create_for_user(db, evk_user.id)
db.commit()
token = EmailVerificationToken.find_valid_token(db, plaintext)
repr_str = repr(token)
assert "EmailVerificationToken" in repr_str
assert str(token.user_id) in repr_str

View File

@@ -0,0 +1,164 @@
# app/modules/tenancy/tests/unit/test_user_auth_service.py
"""Unit tests for UserAuthService."""
import uuid
import pytest
from app.modules.tenancy.exceptions import InvalidTokenException
from app.modules.tenancy.models import User
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
from app.modules.tenancy.services.user_auth_service import UserAuthService
@pytest.mark.unit
@pytest.mark.database
class TestUserAuthServicePasswordReset:
"""Test UserAuthService password reset methods."""
@pytest.fixture
def service(self):
return UserAuthService()
@pytest.fixture
def uas_user(self, db):
"""Create a test user."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"uas_{uid}@test.com",
username=f"uas_{uid}",
hashed_password=auth.hash_password("oldpass123"), # noqa: SEC001
role="store",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def uas_inactive_user(self, db):
"""Create an inactive test user."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"uasi_{uid}@test.com",
username=f"uasi_{uid}",
hashed_password=auth.hash_password("inactive123"), # noqa: SEC001
role="store",
is_active=False,
is_email_verified=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def test_request_password_reset_success(self, service, db, uas_user):
"""Valid active user gets a reset token."""
user, token = service.request_password_reset(db, uas_user.email)
assert user is not None
assert user.id == uas_user.id
assert token is not None
assert len(token) > 20
def test_request_password_reset_nonexistent_email(self, service, db):
"""Non-existent email returns (None, None)."""
user, token = service.request_password_reset(db, "nonexistent@test.com")
assert user is None
assert token is None
def test_request_password_reset_inactive_user(self, service, db, uas_inactive_user):
"""Inactive user returns (None, None)."""
user, token = service.request_password_reset(db, uas_inactive_user.email)
assert user is None
assert token is None
def test_reset_password_success(self, service, db, uas_user):
"""Valid token resets password."""
plaintext = UserPasswordResetToken.create_for_user(db, uas_user.id)
db.commit()
user = service.reset_password(db, plaintext, "newpass456") # noqa: SEC001
db.commit()
assert user.id == uas_user.id
# Verify the password was actually changed
from middleware.auth import AuthManager
auth = AuthManager()
assert auth.verify_password("newpass456", user.hashed_password) # noqa: SEC001
def test_reset_password_invalid_token(self, service, db):
"""Invalid token raises InvalidTokenException."""
with pytest.raises(InvalidTokenException):
service.reset_password(db, "invalid-token", "newpass456") # noqa: SEC001
@pytest.mark.unit
@pytest.mark.database
class TestUserAuthServiceEmailVerification:
"""Test UserAuthService email verification methods."""
@pytest.fixture
def service(self):
return UserAuthService()
@pytest.fixture
def uas_unverified_user(self, db):
"""Create an unverified test user."""
uid = uuid.uuid4().hex[:8]
user = User(
email=f"uasev_{uid}@test.com",
username=f"uasev_{uid}",
hashed_password="hashed_password", # noqa: SEC001
role="store",
is_active=True,
is_email_verified=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def uas_verified_user(self, db):
"""Create a verified test user."""
uid = uuid.uuid4().hex[:8]
user = User(
email=f"uasv_{uid}@test.com",
username=f"uasv_{uid}",
hashed_password="hashed_password", # noqa: SEC001
role="store",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def test_request_verification_resend_unverified(self, service, db, uas_unverified_user):
"""Unverified user gets a new verification token."""
user, token = service.request_verification_resend(db, uas_unverified_user.email)
assert user is not None
assert user.id == uas_unverified_user.id
assert token is not None
def test_request_verification_resend_already_verified(self, service, db, uas_verified_user):
"""Already-verified user returns (None, None)."""
user, token = service.request_verification_resend(db, uas_verified_user.email)
assert user is None
assert token is None
def test_request_verification_resend_nonexistent(self, service, db):
"""Non-existent email returns (None, None)."""
user, token = service.request_verification_resend(db, "nonexistent@test.com")
assert user is None
assert token is None

View File

@@ -0,0 +1,136 @@
# app/modules/tenancy/tests/unit/test_user_password_reset_token.py
"""Unit tests for UserPasswordResetToken model."""
import uuid
from datetime import datetime, timedelta
import pytest
from app.modules.tenancy.models import User
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
@pytest.mark.unit
@pytest.mark.database
class TestUserPasswordResetToken:
"""Test UserPasswordResetToken model CRUD operations."""
@pytest.fixture
def prt_user(self, db):
"""Create a test user for token tests."""
uid = uuid.uuid4().hex[:8]
user = User(
email=f"prt_{uid}@test.com",
username=f"prt_{uid}",
hashed_password="hashed_password", # noqa: SEC001
role="store",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def test_create_for_user(self, db, prt_user):
"""Test creating a reset token returns a plaintext token."""
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
db.commit()
assert plaintext is not None
assert len(plaintext) > 20
def test_find_valid_token(self, db, prt_user):
"""Test finding a valid token by plaintext."""
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
db.commit()
found = UserPasswordResetToken.find_valid_token(db, plaintext)
assert found is not None
assert found.user_id == prt_user.id
assert found.used_at is None
def test_find_valid_token_wrong_token(self, db, prt_user):
"""Test that a wrong token returns None."""
UserPasswordResetToken.create_for_user(db, prt_user.id)
db.commit()
found = UserPasswordResetToken.find_valid_token(db, "wrong-token-value")
assert found is None
def test_mark_used(self, db, prt_user):
"""Test marking a token as used."""
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
db.commit()
token = UserPasswordResetToken.find_valid_token(db, plaintext)
assert token is not None
token.mark_used(db)
db.commit()
assert token.used_at is not None
# Token should no longer be findable
found = UserPasswordResetToken.find_valid_token(db, plaintext)
assert found is None
def test_expired_token_not_found(self, db, prt_user):
"""Test that an expired token is not returned by find_valid_token."""
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
db.commit()
# Manually expire the token
token = UserPasswordResetToken.find_valid_token(db, plaintext)
token.expires_at = datetime.utcnow() - timedelta(hours=1)
db.commit()
found = UserPasswordResetToken.find_valid_token(db, plaintext)
assert found is None
def test_create_invalidates_old_tokens(self, db, prt_user):
"""Test that creating a new token invalidates old unused tokens."""
plaintext_1 = UserPasswordResetToken.create_for_user(db, prt_user.id)
db.commit()
plaintext_2 = UserPasswordResetToken.create_for_user(db, prt_user.id)
db.commit()
# Old token should be gone (deleted)
found_1 = UserPasswordResetToken.find_valid_token(db, plaintext_1)
assert found_1 is None
# New token should work
found_2 = UserPasswordResetToken.find_valid_token(db, plaintext_2)
assert found_2 is not None
def test_hash_token_deterministic(self):
"""Test that hash_token produces consistent results."""
token = "test-token-value"
hash1 = UserPasswordResetToken.hash_token(token)
hash2 = UserPasswordResetToken.hash_token(token)
assert hash1 == hash2
assert len(hash1) == 64 # SHA256 hex digest
def test_token_expiry_is_1_hour(self, db, prt_user):
"""Test that tokens are created with 1-hour expiry."""
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
db.commit()
token = UserPasswordResetToken.find_valid_token(db, plaintext)
assert token is not None
# Expiry should be approximately 1 hour from now
expected = datetime.utcnow() + timedelta(hours=1)
delta = abs((token.expires_at - expected).total_seconds())
assert delta < 60 # Within 60 seconds tolerance
def test_token_repr(self, db, prt_user):
"""Test token __repr__."""
plaintext = UserPasswordResetToken.create_for_user(db, prt_user.id)
db.commit()
token = UserPasswordResetToken.find_valid_token(db, plaintext)
repr_str = repr(token)
assert "UserPasswordResetToken" in repr_str
assert str(token.user_id) in repr_str

View File

@@ -1269,6 +1269,500 @@ If you weren't expecting this invitation, you can safely ignore this email.
Best regards,
The Orion Team
""",
},
# -------------------------------------------------------------------------
# EMAIL VERIFICATION
# -------------------------------------------------------------------------
{
"code": "email_verification",
"language": "en",
"name": "Email Verification",
"description": "Sent to users to verify their email address",
"category": EmailCategory.AUTH.value,
"is_platform_only": False,
"variables": json.dumps([
"first_name", "verification_link", "expiry_hours", "platform_name"
]),
"subject": "Verify Your Email Address",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Verify Your Email</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ first_name }},</p>
<p>Please verify your email address by clicking the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ verification_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Verify Email
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
This link will expire in {{ expiry_hours }} hours. If you didn't create an account, you can safely ignore this email.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ verification_link }}" style="color: #6366f1; word-break: break-all;">{{ verification_link }}</a>
</p>
<p style="margin-top: 30px;">Best regards,<br><strong>The {{ platform_name }} Team</strong></p>
</div>
<div style="text-align: center; padding: 20px; color: #9ca3af; font-size: 12px;">
<p>This is an automated email. Please do not reply directly.</p>
</div>
</body>
</html>""",
"body_text": """Verify Your Email
Hi {{ first_name }},
Please verify your email address by clicking the link below:
{{ verification_link }}
This link will expire in {{ expiry_hours }} hours. If you didn't create an account, you can safely ignore this email.
Best regards,
The {{ platform_name }} Team
""",
},
{
"code": "email_verification",
"language": "fr",
"name": "Verification d'email",
"description": "Envoye aux utilisateurs pour verifier leur adresse email",
"category": EmailCategory.AUTH.value,
"is_platform_only": False,
"variables": json.dumps([
"first_name", "verification_link", "expiry_hours", "platform_name"
]),
"subject": "Verifiez votre adresse email",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Verifiez votre email</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Bonjour {{ first_name }},</p>
<p>Veuillez verifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ verification_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Verifier l'email
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Ce lien expirera dans {{ expiry_hours }} heures. Si vous n'avez pas cree de compte, vous pouvez ignorer cet email.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Si le bouton ne fonctionne pas, copiez et collez ce lien dans votre navigateur :<br>
<a href="{{ verification_link }}" style="color: #6366f1; word-break: break-all;">{{ verification_link }}</a>
</p>
<p style="margin-top: 30px;">Cordialement,<br><strong>L'equipe {{ platform_name }}</strong></p>
</div>
</body>
</html>""",
"body_text": """Verifiez votre email
Bonjour {{ first_name }},
Veuillez verifier votre adresse email en cliquant sur le lien ci-dessous :
{{ verification_link }}
Ce lien expirera dans {{ expiry_hours }} heures. Si vous n'avez pas cree de compte, vous pouvez ignorer cet email.
Cordialement,
L'equipe {{ platform_name }}
""",
},
{
"code": "email_verification",
"language": "de",
"name": "E-Mail-Verifizierung",
"description": "An Benutzer gesendet, um ihre E-Mail-Adresse zu verifizieren",
"category": EmailCategory.AUTH.value,
"is_platform_only": False,
"variables": json.dumps([
"first_name", "verification_link", "expiry_hours", "platform_name"
]),
"subject": "Bestatigen Sie Ihre E-Mail-Adresse",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">E-Mail bestatigen</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hallo {{ first_name }},</p>
<p>Bitte bestatigen Sie Ihre E-Mail-Adresse, indem Sie auf die Schaltflache unten klicken:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ verification_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
E-Mail bestatigen
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Dieser Link lauft in {{ expiry_hours }} Stunden ab. Wenn Sie kein Konto erstellt haben, konnen Sie diese E-Mail ignorieren.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Wenn die Schaltflache nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
<a href="{{ verification_link }}" style="color: #6366f1; word-break: break-all;">{{ verification_link }}</a>
</p>
<p style="margin-top: 30px;">Mit freundlichen Grussen,<br><strong>Das {{ platform_name }}-Team</strong></p>
</div>
</body>
</html>""",
"body_text": """E-Mail bestatigen
Hallo {{ first_name }},
Bitte bestatigen Sie Ihre E-Mail-Adresse, indem Sie auf den Link unten klicken:
{{ verification_link }}
Dieser Link lauft in {{ expiry_hours }} Stunden ab. Wenn Sie kein Konto erstellt haben, konnen Sie diese E-Mail ignorieren.
Mit freundlichen Grussen,
Das {{ platform_name }}-Team
""",
},
{
"code": "email_verification",
"language": "lb",
"name": "E-Mail Verifizéierung",
"description": "Un Benotzer gescheckt fir hir E-Mail-Adress ze verifizéieren",
"category": EmailCategory.AUTH.value,
"is_platform_only": False,
"variables": json.dumps([
"first_name", "verification_link", "expiry_hours", "platform_name"
]),
"subject": "Bestategt Är E-Mail-Adress",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">E-Mail bestategen</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Moien {{ first_name }},</p>
<p>Bestategt w.e.g. Är E-Mail-Adress andeems Dir op de Knäppchen hei drënner klickt:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ verification_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
E-Mail bestategen
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Dese Link leeft an {{ expiry_hours }} Stonnen of. Wann Dir kee Kont erstallt hutt, kennt Dir des E-Mail ignoréieren.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Wann de Knappchen net fonctionnéiert, kopéiert dese Link an Are Browser:<br>
<a href="{{ verification_link }}" style="color: #6366f1; word-break: break-all;">{{ verification_link }}</a>
</p>
<p style="margin-top: 30px;">Mat beschte Greiss,<br><strong>D'{{ platform_name }} Team</strong></p>
</div>
</body>
</html>""",
"body_text": """E-Mail bestategen
Moien {{ first_name }},
Bestategt w.e.g. Ar E-Mail-Adress andeems Dir op de Link hei drenner klickt:
{{ verification_link }}
Dese Link leeft an {{ expiry_hours }} Stonnen of. Wann Dir kee Kont erstallt hutt, kennt Dir des E-Mail ignoréieren.
Mat beschte Greiss,
D'{{ platform_name }} Team
""",
},
# -------------------------------------------------------------------------
# MERCHANT PASSWORD RESET
# -------------------------------------------------------------------------
{
"code": "merchant_password_reset",
"language": "en",
"name": "Merchant Password Reset",
"description": "Sent to merchants/store users when they request a password reset",
"category": EmailCategory.AUTH.value,
"is_platform_only": False,
"variables": json.dumps([
"first_name", "reset_link", "expiry_hours", "platform_name"
]),
"subject": "Reset Your Password",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Reset Your Password</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hi {{ first_name }},</p>
<p>We received a request to reset your password. Click the button below to create a new password:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Reset Password
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
This link will expire in {{ expiry_hours }} hour(s). If you didn't request this password reset, you can safely ignore this email.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Best regards,<br><strong>The {{ platform_name }} Team</strong></p>
</div>
<div style="text-align: center; padding: 20px; color: #9ca3af; font-size: 12px;">
<p>This is an automated email. Please do not reply directly.</p>
</div>
</body>
</html>""",
"body_text": """Reset Your Password
Hi {{ first_name }},
We received a request to reset your password. Click the link below to create a new password:
{{ reset_link }}
This link will expire in {{ expiry_hours }} hour(s). If you didn't request this password reset, you can safely ignore this email.
Best regards,
The {{ platform_name }} Team
""",
},
{
"code": "merchant_password_reset",
"language": "fr",
"name": "Reinitialisation mot de passe marchand",
"description": "Envoye aux marchands lorsqu'ils demandent une reinitialisation de mot de passe",
"category": EmailCategory.AUTH.value,
"is_platform_only": False,
"variables": json.dumps([
"first_name", "reset_link", "expiry_hours", "platform_name"
]),
"subject": "Reinitialiser votre mot de passe",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Reinitialiser votre mot de passe</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Bonjour {{ first_name }},</p>
<p>Nous avons recu une demande de reinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour creer un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Reinitialiser le mot de passe
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Ce lien expirera dans {{ expiry_hours }} heure(s). Si vous n'avez pas demande cette reinitialisation, vous pouvez ignorer cet email.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Si le bouton ne fonctionne pas, copiez et collez ce lien dans votre navigateur :<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Cordialement,<br><strong>L'equipe {{ platform_name }}</strong></p>
</div>
</body>
</html>""",
"body_text": """Reinitialiser votre mot de passe
Bonjour {{ first_name }},
Nous avons recu une demande de reinitialisation de votre mot de passe. Cliquez sur le lien ci-dessous :
{{ reset_link }}
Ce lien expirera dans {{ expiry_hours }} heure(s). Si vous n'avez pas demande cette reinitialisation, vous pouvez ignorer cet email.
Cordialement,
L'equipe {{ platform_name }}
""",
},
{
"code": "merchant_password_reset",
"language": "de",
"name": "Handler Passwort zurucksetzen",
"description": "An Handler gesendet, wenn sie eine Passwortzurucksetzung anfordern",
"category": EmailCategory.AUTH.value,
"is_platform_only": False,
"variables": json.dumps([
"first_name", "reset_link", "expiry_hours", "platform_name"
]),
"subject": "Passwort zurucksetzen",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Passwort zurucksetzen</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Hallo {{ first_name }},</p>
<p>Wir haben eine Anfrage zur Zurucksetzung Ihres Passworts erhalten. Klicken Sie auf die Schaltflache unten, um ein neues Passwort zu erstellen:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Passwort zurucksetzen
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Dieser Link lauft in {{ expiry_hours }} Stunde(n) ab. Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, konnen Sie diese E-Mail ignorieren.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Wenn die Schaltflache nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Mit freundlichen Grussen,<br><strong>Das {{ platform_name }}-Team</strong></p>
</div>
</body>
</html>""",
"body_text": """Passwort zurucksetzen
Hallo {{ first_name }},
Wir haben eine Anfrage zur Zurucksetzung Ihres Passworts erhalten. Klicken Sie auf den Link unten:
{{ reset_link }}
Dieser Link lauft in {{ expiry_hours }} Stunde(n) ab. Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, konnen Sie diese E-Mail ignorieren.
Mit freundlichen Grussen,
Das {{ platform_name }}-Team
""",
},
{
"code": "merchant_password_reset",
"language": "lb",
"name": "Handler Passwuert zrecksetzen",
"description": "Un Handler gescheckt wann si eng Passwuertzrecksetzung ufroen",
"category": EmailCategory.AUTH.value,
"is_platform_only": False,
"variables": json.dumps([
"first_name", "reset_link", "expiry_hours", "platform_name"
]),
"subject": "Passwuert zrecksetzen",
"body_html": """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Passwuert zrecksetzen</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
<p style="font-size: 16px;">Moien {{ first_name }},</p>
<p>Mir hunn eng Ufro kritt fir Aert Passwuert zreckzesetzen. Klickt op de Knappchen hei drenner fir en neit Passwuert ze kreéieren:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ reset_link }}" style="background: #6366f1; color: white; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">
Passwuert zrecksetzen
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
Dese Link leeft an {{ expiry_hours }} Stonn(en) of. Wann Dir des Passwuertzrecksetzung net ugefrot hutt, kennt Dir des E-Mail ignoréieren.
</p>
<p style="color: #6b7280; font-size: 14px; margin-top: 20px;">
Wann de Knappchen net fonctionnéiert, kopéiert dese Link an Are Browser:<br>
<a href="{{ reset_link }}" style="color: #6366f1; word-break: break-all;">{{ reset_link }}</a>
</p>
<p style="margin-top: 30px;">Mat beschte Greiss,<br><strong>D'{{ platform_name }} Team</strong></p>
</div>
</body>
</html>""",
"body_text": """Passwuert zrecksetzen
Moien {{ first_name }},
Mir hunn eng Ufro kritt fir Aert Passwuert zreckzesetzen. Klickt op de Link hei drenner:
{{ reset_link }}
Dese Link leeft an {{ expiry_hours }} Stonn(en) of. Wann Dir des Passwuertzrecksetzung net ugefrot hutt, kennt Dir des E-Mail ignoréieren.
Mat beschte Greiss,
D'{{ platform_name }} Team
""",
},
]

View File

@@ -80,6 +80,7 @@ def platform_admin_with_platform(db, auth_manager, test_platform, test_super_adm
hashed_password=hashed_password,
role="admin",
is_active=True,
is_email_verified=True,
is_super_admin=False,
)
db.add(admin)

View File

@@ -31,6 +31,7 @@ def test_user(db, auth_manager):
hashed_password=hashed_password,
role="user",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()
@@ -49,6 +50,7 @@ def test_admin(db, auth_manager):
hashed_password=hashed_password,
role="admin",
is_active=True,
is_email_verified=True,
is_super_admin=True, # Full platform access
)
db.add(admin)
@@ -68,6 +70,7 @@ def test_super_admin(db, auth_manager):
hashed_password=hashed_password,
role="admin",
is_active=True,
is_email_verified=True,
is_super_admin=True,
)
db.add(admin)
@@ -87,6 +90,7 @@ def test_platform_admin(db, auth_manager):
hashed_password=hashed_password,
role="admin",
is_active=True,
is_email_verified=True,
is_super_admin=False, # Platform admin, not super admin
)
db.add(admin)
@@ -130,6 +134,7 @@ def another_admin(db, auth_manager):
hashed_password=hashed_password,
role="admin",
is_active=True,
is_email_verified=True,
is_super_admin=True, # Full platform access
)
db.add(admin)
@@ -149,6 +154,7 @@ def other_user(db, auth_manager):
hashed_password=hashed_password,
role="user",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()
@@ -190,6 +196,7 @@ def test_store_user(db, auth_manager):
hashed_password=hashed_password,
role="store",
is_active=True,
is_email_verified=True,
)
db.add(user)
db.commit()