docs: add comprehensive documentation for today's work
Technical Documentation: - docs/development/architecture-fixes-2026-01.md: Complete guide to all architecture validation fixes (62 -> 0 errors) User Guides: - docs/guides/email-templates.md: How-to guide for vendors and admins to use the email template customization system Implementation Docs: - docs/implementation/password-reset-implementation.md: Technical documentation for the password reset feature - Updated email-templates-architecture.md with EmailTemplateService documentation and related links Bugfix: - Fixed TemplateListItem Pydantic model to match service output (languages vs available_languages field name) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,59 @@ UNIQUE (vendor_id, template_code, language)
|
||||
|
||||
---
|
||||
|
||||
## Email Template Service
|
||||
|
||||
**File:** `app/services/email_template_service.py`
|
||||
|
||||
The `EmailTemplateService` encapsulates all email template business logic, keeping API endpoints clean and focused on request/response handling.
|
||||
|
||||
### Admin Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `list_platform_templates()` | List all platform templates grouped by code |
|
||||
| `get_template_categories()` | Get list of template categories |
|
||||
| `get_platform_template(code)` | Get template with all language versions |
|
||||
| `update_platform_template(code, language, data)` | Update platform template content |
|
||||
| `preview_template(code, language, variables)` | Generate preview with sample data |
|
||||
| `get_template_logs(code, limit)` | Get email logs for template |
|
||||
|
||||
### Vendor Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `list_overridable_templates(vendor_id)` | List templates vendor can customize |
|
||||
| `get_vendor_template(vendor_id, code, language)` | Get template (override or platform default) |
|
||||
| `create_or_update_vendor_override(vendor_id, code, language, data)` | Save vendor customization |
|
||||
| `delete_vendor_override(vendor_id, code, language)` | Revert to platform default |
|
||||
| `preview_vendor_template(vendor_id, code, language, variables)` | Preview with vendor branding |
|
||||
|
||||
### Usage Example
|
||||
|
||||
```python
|
||||
from app.services.email_template_service import EmailTemplateService
|
||||
|
||||
service = EmailTemplateService(db)
|
||||
|
||||
# List templates for admin
|
||||
templates = service.list_platform_templates()
|
||||
|
||||
# Get vendor's view of a template
|
||||
template_data = service.get_vendor_template(vendor_id, "order_confirmation", "fr")
|
||||
|
||||
# Create vendor override
|
||||
service.create_or_update_vendor_override(
|
||||
vendor_id=vendor.id,
|
||||
code="order_confirmation",
|
||||
language="fr",
|
||||
subject="Votre commande {{ order_number }}",
|
||||
body_html="<html>...</html>",
|
||||
body_text="Plain text...",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email Service
|
||||
|
||||
**File:** `app/services/email_service.py`
|
||||
@@ -372,7 +425,8 @@ db.commit()
|
||||
│ │ ├── admin_pages.py (route added)
|
||||
│ │ └── vendor_pages.py (route added)
|
||||
│ ├── services/
|
||||
│ │ └── email_service.py (enhanced)
|
||||
│ │ ├── email_service.py (enhanced)
|
||||
│ │ └── email_template_service.py (new - business logic)
|
||||
│ └── templates/
|
||||
│ ├── admin/
|
||||
│ │ ├── email-templates.html
|
||||
@@ -394,3 +448,11 @@ db.commit()
|
||||
└── vendor/js/
|
||||
└── email-templates.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Email Templates User Guide](../guides/email-templates.md) - How to use the email template system
|
||||
- [Password Reset Implementation](./password-reset-implementation.md) - Password reset feature using email templates
|
||||
- [Architecture Fixes (January 2026)](../development/architecture-fixes-2026-01.md) - Architecture validation fixes
|
||||
|
||||
400
docs/implementation/password-reset-implementation.md
Normal file
400
docs/implementation/password-reset-implementation.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# Password Reset Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the password reset feature for shop customers, allowing them to securely reset their password via email.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Flow Diagram
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Customer │───▶│ Forgot Page │───▶│ API: POST │───▶│ Email Sent │
|
||||
│ Clicks │ │ Enter Email │ │ /forgot-pwd │ │ with Link │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Success │◀───│ API: POST │◀───│ Reset Page │◀───│ Customer │
|
||||
│ Redirect │ │ /reset-pwd │ │ New Password │ │ Clicks Link │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Database Model
|
||||
|
||||
**File:** `models/database/password_reset_token.py`
|
||||
|
||||
```python
|
||||
class PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id: int # Primary key
|
||||
customer_id: int # FK to customers.id
|
||||
token_hash: str # SHA256 hash of token
|
||||
expires_at: datetime # Expiration timestamp
|
||||
used_at: datetime | None # When token was used
|
||||
created_at: datetime # Creation timestamp
|
||||
|
||||
TOKEN_EXPIRY_HOURS = 1 # Token valid for 1 hour
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create_for_customer(db, customer_id)` | Creates new token, returns plaintext |
|
||||
| `find_valid_token(db, plaintext_token)` | Finds unexpired, unused token |
|
||||
| `mark_used(db)` | Marks token as used |
|
||||
|
||||
**Security:**
|
||||
- Tokens are hashed with SHA256 before storage
|
||||
- Only one active token per customer (old tokens invalidated)
|
||||
- Tokens expire after 1 hour
|
||||
- Tokens can only be used once
|
||||
|
||||
---
|
||||
|
||||
### API Endpoints
|
||||
|
||||
**File:** `app/api/v1/shop/auth.py`
|
||||
|
||||
#### POST /api/v1/shop/auth/forgot-password
|
||||
|
||||
Request a password reset link.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"email": "customer@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** (Always returns success to prevent email enumeration)
|
||||
```json
|
||||
{
|
||||
"message": "If an account exists with this email, a password reset link has been sent."
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse)
|
||||
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
# Look up customer (vendor-scoped)
|
||||
customer = customer_service.get_customer_for_password_reset(db, vendor.id, email)
|
||||
|
||||
if customer:
|
||||
# Generate token and send email
|
||||
plaintext_token = PasswordResetToken.create_for_customer(db, customer.id)
|
||||
reset_link = f"{scheme}://{host}/shop/account/reset-password?token={plaintext_token}"
|
||||
|
||||
email_service.send_template(
|
||||
template_code="password_reset",
|
||||
to_email=customer.email,
|
||||
variables={
|
||||
"customer_name": customer.first_name,
|
||||
"reset_link": reset_link,
|
||||
"expiry_hours": str(PasswordResetToken.TOKEN_EXPIRY_HOURS),
|
||||
},
|
||||
vendor_id=vendor.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Always return success (security: don't reveal if email exists)
|
||||
return PasswordResetRequestResponse(
|
||||
message="If an account exists with this email, a password reset link has been sent."
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### POST /api/v1/shop/auth/reset-password
|
||||
|
||||
Reset password using token from email.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"reset_token": "abc123...",
|
||||
"new_password": "newSecurePassword123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Password reset successfully. You can now log in with your new password."
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
|
||||
def reset_password(
|
||||
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
|
||||
):
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if not vendor:
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
# Service handles all validation and password update
|
||||
customer = customer_service.validate_and_reset_password(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
reset_token=reset_token,
|
||||
new_password=new_password,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return PasswordResetResponse(
|
||||
message="Password reset successfully. You can now log in with your new password."
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Service Layer
|
||||
|
||||
**File:** `app/services/customer_service.py`
|
||||
|
||||
#### get_customer_for_password_reset
|
||||
|
||||
```python
|
||||
def get_customer_for_password_reset(
|
||||
self, db: Session, vendor_id: int, email: str
|
||||
) -> Customer | None:
|
||||
"""Get active customer by email for password reset.
|
||||
|
||||
Returns None if customer doesn't exist or is inactive.
|
||||
This method is designed to not reveal whether an email exists.
|
||||
"""
|
||||
return (
|
||||
db.query(Customer)
|
||||
.filter(
|
||||
Customer.vendor_id == vendor_id,
|
||||
Customer.email == email.lower(),
|
||||
Customer.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
```
|
||||
|
||||
#### validate_and_reset_password
|
||||
|
||||
```python
|
||||
def validate_and_reset_password(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
reset_token: str,
|
||||
new_password: str,
|
||||
) -> Customer:
|
||||
"""Validate reset token and update customer password.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID (for security verification)
|
||||
reset_token: Password reset token from email
|
||||
new_password: New password
|
||||
|
||||
Returns:
|
||||
Customer: Updated customer
|
||||
|
||||
Raises:
|
||||
PasswordTooShortException: If password < 8 characters
|
||||
InvalidPasswordResetTokenException: If token invalid/expired
|
||||
CustomerNotActiveException: If customer account is inactive
|
||||
"""
|
||||
# Validate password length
|
||||
if len(new_password) < 8:
|
||||
raise PasswordTooShortException(min_length=8)
|
||||
|
||||
# Find valid token
|
||||
token_record = PasswordResetToken.find_valid_token(db, reset_token)
|
||||
if not token_record:
|
||||
raise InvalidPasswordResetTokenException()
|
||||
|
||||
# Get customer and verify vendor ownership
|
||||
customer = db.query(Customer).filter(Customer.id == token_record.customer_id).first()
|
||||
if not customer or customer.vendor_id != vendor_id:
|
||||
raise InvalidPasswordResetTokenException()
|
||||
|
||||
if not customer.is_active:
|
||||
raise CustomerNotActiveException(customer.email)
|
||||
|
||||
# Update password
|
||||
hashed_password = self.auth_service.hash_password(new_password)
|
||||
customer.hashed_password = hashed_password
|
||||
|
||||
# Mark token as used
|
||||
token_record.mark_used(db)
|
||||
|
||||
return customer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Exceptions
|
||||
|
||||
**File:** `app/exceptions/customer.py`
|
||||
|
||||
```python
|
||||
class InvalidPasswordResetTokenException(ValidationException):
|
||||
"""Raised when password reset token is invalid or expired."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="Invalid or expired password reset link. Please request a new one.",
|
||||
field="reset_token",
|
||||
)
|
||||
self.error_code = "INVALID_RESET_TOKEN"
|
||||
|
||||
|
||||
class PasswordTooShortException(ValidationException):
|
||||
"""Raised when password doesn't meet minimum length requirement."""
|
||||
|
||||
def __init__(self, min_length: int = 8):
|
||||
super().__init__(
|
||||
message=f"Password must be at least {min_length} characters long",
|
||||
field="password",
|
||||
details={"min_length": min_length},
|
||||
)
|
||||
self.error_code = "PASSWORD_TOO_SHORT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Frontend Pages
|
||||
|
||||
#### Forgot Password Page
|
||||
|
||||
**Template:** `app/templates/shop/account/forgot-password.html`
|
||||
**Route:** `/shop/account/forgot-password`
|
||||
|
||||
Features:
|
||||
- Email input form
|
||||
- Form validation
|
||||
- Success message display
|
||||
- Link back to login
|
||||
|
||||
#### Reset Password Page
|
||||
|
||||
**Template:** `app/templates/shop/account/reset-password.html`
|
||||
**Route:** `/shop/account/reset-password?token=...`
|
||||
|
||||
Features:
|
||||
- New password input
|
||||
- Confirm password input
|
||||
- Password strength indicator
|
||||
- Password match validation
|
||||
- Success redirect to login
|
||||
|
||||
---
|
||||
|
||||
### Email Template
|
||||
|
||||
**Code:** `password_reset`
|
||||
**Category:** AUTH
|
||||
**Languages:** en, fr, de, lb
|
||||
|
||||
**Variables:**
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `customer_name` | Customer's first name |
|
||||
| `reset_link` | Full URL to reset password page |
|
||||
| `expiry_hours` | Hours until link expires |
|
||||
|
||||
**Example Email:**
|
||||
```
|
||||
Subject: Reset Your Password
|
||||
|
||||
Hi {{ customer_name }},
|
||||
|
||||
We received a request to reset your password.
|
||||
|
||||
Click here to reset your password:
|
||||
{{ reset_link }}
|
||||
|
||||
This link will expire in {{ expiry_hours }} hour(s).
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Security
|
||||
|
||||
1. **Hashed Storage**: Tokens stored as SHA256 hashes
|
||||
2. **Short Expiry**: 1 hour expiration
|
||||
3. **Single Use**: Tokens invalidated after use
|
||||
4. **One Active Token**: Creating new token invalidates previous
|
||||
|
||||
### Email Enumeration Prevention
|
||||
|
||||
- Same response whether email exists or not
|
||||
- Same response timing regardless of email existence
|
||||
|
||||
### Vendor Isolation
|
||||
|
||||
- Tokens are verified against the requesting vendor
|
||||
- Prevents cross-vendor token reuse
|
||||
|
||||
### Password Requirements
|
||||
|
||||
- Minimum 8 characters
|
||||
- Enforced by service layer exception
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Request reset for existing email (email received)
|
||||
- [ ] Request reset for non-existent email (same response)
|
||||
- [ ] Click reset link within expiry (success)
|
||||
- [ ] Click reset link after expiry (error)
|
||||
- [ ] Click reset link twice (second fails)
|
||||
- [ ] Password too short (validation error)
|
||||
- [ ] Password mismatch on form (client validation)
|
||||
- [ ] Multi-language email templates
|
||||
- [ ] Token works only for correct vendor
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
├── alembic/versions/
|
||||
│ └── t8b9c0d1e2f3_add_password_reset_tokens.py
|
||||
├── app/
|
||||
│ ├── api/v1/shop/
|
||||
│ │ └── auth.py
|
||||
│ ├── exceptions/
|
||||
│ │ └── customer.py
|
||||
│ ├── routes/
|
||||
│ │ └── shop_pages.py
|
||||
│ ├── services/
|
||||
│ │ └── customer_service.py
|
||||
│ └── templates/shop/account/
|
||||
│ ├── forgot-password.html
|
||||
│ └── reset-password.html
|
||||
├── models/
|
||||
│ ├── database/
|
||||
│ │ └── password_reset_token.py
|
||||
│ └── schema/
|
||||
│ └── auth.py
|
||||
└── scripts/
|
||||
└── seed_email_templates.py
|
||||
```
|
||||
Reference in New Issue
Block a user