fix: customer authentication and shop error page styling

## Customer Authentication Fixes
- Fix get_current_customer_api to properly decode customer tokens (was using User model)
- Add _validate_customer_token() helper for shared customer token validation
- Add vendor validation: token.vendor_id must match request URL vendor
- Block admin/vendor tokens from shop endpoints (type != "customer")
- Update get_current_customer_optional to use proper customer token validation
- Customer auth functions now return Customer object (not User)

## Shop Orders API
- Update orders.py to receive Customer directly from auth dependency
- Remove broken get_customer_from_user() helper
- Use VendorNotFoundException instead of HTTPException

## Shop Error Pages
- Fix all error templates (400, 401, 403, 404, 422, 429, 500, 502, generic)
- Templates were using undefined CSS classes (.btn, .status-code, etc.)
- Now properly extend base.html and override specific blocks
- Use Tailwind utility classes for consistent styling

## Documentation
- Update docs/api/authentication.md with new Customer return types
- Document vendor validation security features
- Update docs/api/authentication-quick-reference.md examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-04 22:48:02 +01:00
parent 8a367077e1
commit cbfbbb4654
13 changed files with 302 additions and 394 deletions

View File

@@ -328,33 +328,27 @@ def get_current_vendor_api(
# ============================================================================ # ============================================================================
def get_current_customer_from_cookie_or_header( def _validate_customer_token(token: str, request: Request, db: Session):
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
customer_token: str | None = Cookie(None),
db: Session = Depends(get_db),
):
""" """
Get current customer from customer_token cookie or Authorization header. Validate customer JWT token and return Customer object.
Used for shop account HTML pages (/shop/account/*) that need cookie-based auth. Validates:
Note: Public shop pages (/shop/products, etc.) don't use this dependency. 1. Token signature and expiration
2. Token type is "customer"
Priority: 3. Customer exists and is active
1. Authorization header (API calls) 4. Token vendor_id matches request vendor (URL-based)
2. customer_token cookie (page navigation)
Args: Args:
request: FastAPI request token: JWT token string
credentials: Optional Bearer token from header request: FastAPI request (for vendor context)
customer_token: Optional token from customer_token cookie
db: Database session db: Database session
Returns: Returns:
Customer: Authenticated customer object Customer: Authenticated customer object
Raises: Raises:
InvalidTokenException: If no token or invalid token InvalidTokenException: If token is invalid or expired
UnauthorizedVendorAccessException: If vendor mismatch
""" """
from datetime import datetime from datetime import datetime
@@ -362,14 +356,6 @@ def get_current_customer_from_cookie_or_header(
from models.database.customer import Customer from models.database.customer import Customer
token, source = _get_token_from_request(
credentials, customer_token, "customer_token", str(request.url.path)
)
if not token:
logger.warning(f"Customer auth failed: No token for {request.url.path}")
raise InvalidTokenException("Customer authentication required")
# Decode and validate customer JWT token # Decode and validate customer JWT token
try: try:
payload = jwt.decode( payload = jwt.decode(
@@ -394,6 +380,9 @@ def get_current_customer_from_cookie_or_header(
logger.warning(f"Expired customer token for customer_id={customer_id}") logger.warning(f"Expired customer token for customer_id={customer_id}")
raise InvalidTokenException("Token has expired") raise InvalidTokenException("Token has expired")
# Get vendor_id from token for validation
token_vendor_id = payload.get("vendor_id")
except JWTError as e: except JWTError as e:
logger.warning(f"JWT decode error: {str(e)}") logger.warning(f"JWT decode error: {str(e)}")
raise InvalidTokenException("Could not validate credentials") raise InvalidTokenException("Could not validate credentials")
@@ -409,51 +398,94 @@ def get_current_customer_from_cookie_or_header(
logger.warning(f"Inactive customer attempted access: {customer.email}") logger.warning(f"Inactive customer attempted access: {customer.email}")
raise InvalidTokenException("Customer account is inactive") raise InvalidTokenException("Customer account is inactive")
# Validate vendor context matches token
# This prevents using a customer token from vendor A on vendor B's shop
request_vendor = getattr(request.state, "vendor", None)
if request_vendor and token_vendor_id:
if request_vendor.id != token_vendor_id:
logger.warning(
f"Customer {customer.email} token vendor mismatch: "
f"token={token_vendor_id}, request={request_vendor.id}"
)
raise UnauthorizedVendorAccessException(
vendor_code=request_vendor.vendor_code,
user_id=customer.id,
)
logger.debug(f"Customer authenticated: {customer.email} (ID: {customer.id})") logger.debug(f"Customer authenticated: {customer.email} (ID: {customer.id})")
return customer return customer
def get_current_customer_api( def get_current_customer_from_cookie_or_header(
credentials: HTTPAuthorizationCredentials = Depends(security), request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
customer_token: str | None = Cookie(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> User: ):
""" """
Get current customer user from Authorization header ONLY. Get current customer from customer_token cookie or Authorization header.
Used for shop API endpoints that should not accept cookies. Used for shop account HTML pages (/shop/account/*) that need cookie-based auth.
Note: Public shop pages (/shop/products, etc.) don't use this dependency.
Validates that token vendor_id matches request vendor (URL-based detection).
Priority:
1. Authorization header (API calls)
2. customer_token cookie (page navigation)
Args: Args:
request: FastAPI request
credentials: Optional Bearer token from header
customer_token: Optional token from customer_token cookie
db: Database session
Returns:
Customer: Authenticated customer object
Raises:
InvalidTokenException: If no token or invalid token
UnauthorizedVendorAccessException: If vendor mismatch
"""
token, source = _get_token_from_request(
credentials, customer_token, "customer_token", str(request.url.path)
)
if not token:
logger.warning(f"Customer auth failed: No token for {request.url.path}")
raise InvalidTokenException("Customer authentication required")
return _validate_customer_token(token, request, db)
def get_current_customer_api(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
):
"""
Get current customer from Authorization header ONLY.
Used for shop API endpoints that should not accept cookies.
Validates that token vendor_id matches request vendor (URL-based detection).
Args:
request: FastAPI request (for vendor context)
credentials: Bearer token from Authorization header credentials: Bearer token from Authorization header
db: Database session db: Database session
Returns: Returns:
User: Authenticated customer user Customer: Authenticated customer object
Raises: Raises:
InvalidTokenException: If no token or invalid token InvalidTokenException: If no token or invalid token
InsufficientPermissionsException: If user is not customer (admin/vendor blocked) UnauthorizedVendorAccessException: If vendor mismatch
""" """
if not credentials: if not credentials:
raise InvalidTokenException("Authorization header required for API calls") raise InvalidTokenException("Authorization header required for API calls")
user = _validate_user_token(credentials.credentials, db) return _validate_customer_token(credentials.credentials, request, db)
# Block admins from customer API
if user.role == "admin":
logger.warning(f"Admin user {user.username} attempted customer API")
raise InsufficientPermissionsException("Customer access only")
# Block vendors from customer API
if user.role == "vendor":
logger.warning(f"Vendor user {user.username} attempted customer API")
raise InsufficientPermissionsException("Customer access only")
if user.role != "customer":
logger.warning(f"Non-customer user {user.username} attempted customer API")
raise InsufficientPermissionsException("Customer privileges required")
return user
# ============================================================================ # ============================================================================
@@ -883,9 +915,9 @@ def get_current_customer_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(security), credentials: HTTPAuthorizationCredentials | None = Depends(security),
customer_token: str | None = Cookie(None), customer_token: str | None = Cookie(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> User | None: ):
""" """
Get current customer user from customer_token cookie or Authorization header. Get current customer from customer_token cookie or Authorization header.
Returns None instead of raising exceptions if not authenticated. Returns None instead of raising exceptions if not authenticated.
Used for login pages to check if user is already authenticated. Used for login pages to check if user is already authenticated.
@@ -901,8 +933,8 @@ def get_current_customer_optional(
db: Database session db: Database session
Returns: Returns:
User: Authenticated customer user if valid token exists Customer: Authenticated customer if valid token exists
None: If no token, invalid token, or user is not customer None: If no token, invalid token, or vendor mismatch
""" """
token, source = _get_token_from_request( token, source = _get_token_from_request(
credentials, customer_token, "customer_token", str(request.url.path) credentials, customer_token, "customer_token", str(request.url.path)
@@ -912,14 +944,8 @@ def get_current_customer_optional(
return None return None
try: try:
# Validate token and get user # Validate customer token (includes vendor validation)
user = _validate_user_token(token, db) return _validate_customer_token(token, request, db)
# Verify user is customer
if user.role == "customer":
return user
except Exception: except Exception:
# Invalid token or other error # Invalid token, vendor mismatch, or other error
pass return None
return None

View File

@@ -1,23 +1,26 @@
# app/api/v1/shop/orders.py # app/api/v1/shop/orders.py
""" """
Shop Orders API (Public) Shop Orders API (Customer authenticated)
Public endpoints for managing customer orders in shop frontend. Endpoints for managing customer orders in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware). Uses vendor from request.state (injected by VendorContextMiddleware).
Requires customer authentication for most operations. Requires customer authentication - get_current_customer_api validates
that customer token vendor_id matches the URL vendor.
Customer Context: get_current_customer_api returns Customer directly
(not User), with vendor validation already performed.
""" """
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api from app.api.deps import get_current_customer_api
from app.core.database import get_db from app.core.database import get_db
from app.services.customer_service import customer_service from app.exceptions import VendorNotFoundException
from app.services.order_service import order_service from app.services.order_service import order_service
from models.database.customer import Customer from models.database.customer import Customer
from models.database.user import User
from models.schema.order import ( from models.schema.order import (
OrderCreate, OrderCreate,
OrderDetailResponse, OrderDetailResponse,
@@ -29,47 +32,11 @@ router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_customer_from_user(request: Request, user: User, db: Session) -> Customer:
"""
Helper to get Customer record from authenticated User.
Args:
request: FastAPI request (to get vendor)
user: Authenticated user
db: Database session
Returns:
Customer record
Raises:
HTTPException: If customer not found or vendor mismatch
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
# Find customer record for this user and vendor
customer = customer_service.get_customer_by_user_id(
db=db, vendor_id=vendor.id, user_id=user.id
)
if not customer:
raise HTTPException(
status_code=404, detail="Customer account not found for current vendor"
)
return customer
@router.post("/orders", response_model=OrderResponse) @router.post("/orders", response_model=OrderResponse)
def place_order( def place_order(
request: Request, request: Request,
order_data: OrderCreate, order_data: OrderCreate,
current_user: User = Depends(get_current_customer_api), customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
@@ -82,17 +49,11 @@ def place_order(
Request Body: Request Body:
- Order data including shipping address, payment method, etc. - Order data including shipping address, payment method, etc.
""" """
# Get vendor from middleware # Get vendor from middleware (already validated by get_current_customer_api)
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
if not vendor: if not vendor:
raise HTTPException( raise VendorNotFoundException("context", identifier_type="subdomain")
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
# Get customer record
customer = get_customer_from_user(request, current_user, db)
logger.debug( logger.debug(
f"[SHOP_API] place_order for customer {customer.id}", f"[SHOP_API] place_order for customer {customer.id}",
@@ -100,7 +61,6 @@ def place_order(
"vendor_id": vendor.id, "vendor_id": vendor.id,
"vendor_code": vendor.subdomain, "vendor_code": vendor.subdomain,
"customer_id": customer.id, "customer_id": customer.id,
"user_id": current_user.id,
}, },
) )
@@ -132,7 +92,7 @@ def get_my_orders(
request: Request, request: Request,
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
current_user: User = Depends(get_current_customer_api), customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
@@ -145,17 +105,11 @@ def get_my_orders(
- skip: Number of orders to skip (pagination) - skip: Number of orders to skip (pagination)
- limit: Maximum number of orders to return - limit: Maximum number of orders to return
""" """
# Get vendor from middleware # Get vendor from middleware (already validated by get_current_customer_api)
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
if not vendor: if not vendor:
raise HTTPException( raise VendorNotFoundException("context", identifier_type="subdomain")
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
# Get customer record
customer = get_customer_from_user(request, current_user, db)
logger.debug( logger.debug(
f"[SHOP_API] get_my_orders for customer {customer.id}", f"[SHOP_API] get_my_orders for customer {customer.id}",
@@ -185,7 +139,7 @@ def get_my_orders(
def get_order_details( def get_order_details(
request: Request, request: Request,
order_id: int = Path(..., description="Order ID", gt=0), order_id: int = Path(..., description="Order ID", gt=0),
current_user: User = Depends(get_current_customer_api), customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
@@ -197,17 +151,11 @@ def get_order_details(
Path Parameters: Path Parameters:
- order_id: ID of the order to retrieve - order_id: ID of the order to retrieve
""" """
# Get vendor from middleware # Get vendor from middleware (already validated by get_current_customer_api)
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
if not vendor: if not vendor:
raise HTTPException( raise VendorNotFoundException("context", identifier_type="subdomain")
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
# Get customer record
customer = get_customer_from_user(request, current_user, db)
logger.debug( logger.debug(
f"[SHOP_API] get_order_details: order {order_id}", f"[SHOP_API] get_order_details: order {order_id}",

View File

@@ -1,33 +1,22 @@
{# app/templates/shop/errors/400.html #}
{# 400 Bad Request error page #}
{% extends "shop/errors/base.html" %} {% extends "shop/errors/base.html" %}
{% block icon %}❌{% endblock %} {% block icon %}❌{% endblock %}
{% block title %}400 - Invalid Request{% endblock %} {% block title %}400 - Invalid Request{% endblock %}
{% block content %} {% block action_buttons %}
{% if vendor and theme and theme.branding and theme.branding.logo %} <a href="javascript:history.back()"
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
{% endif %} Go Back
</a>
<a href="{{ base_url }}shop/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
<div class="error-icon"></div> {% block support_link %}
<div class="status-code">400</div> Need help? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
<div class="status-name">Invalid Request</div> {% endblock %}
<div class="error-message">
The request couldn't be processed. This might be due to invalid information or a technical issue.
</div>
<div class="action-buttons">
<a href="javascript:history.back()" class="btn btn-primary">Go Back</a>
<a href="{{ base_url }}shop/" class="btn btn-secondary">Go to Home</a>
</div>
<div class="support-link">
Need help? <a href="{{ base_url }}shop/contact">Contact us</a>
</div>
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,33 +1,22 @@
{# app/templates/shop/errors/401.html #}
{# 401 Unauthorized error page - prompts login #}
{% extends "shop/errors/base.html" %} {% extends "shop/errors/base.html" %}
{% block icon %}🔐{% endblock %} {% block icon %}🔐{% endblock %}
{% block title %}401 - Authentication Required{% endblock %} {% block title %}401 - Authentication Required{% endblock %}
{% block content %} {% block action_buttons %}
{% if vendor and theme and theme.branding and theme.branding.logo %} <a href="{{ base_url }}shop/account/login"
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
{% endif %} Log In
</a>
<a href="{{ base_url }}shop/account/register"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Create Account
</a>
{% endblock %}
<div class="error-icon">🔐</div> {% block support_link %}
<div class="status-code">401</div> Don't have an account? <a href="{{ base_url }}shop/account/register" class="text-theme-primary font-semibold hover:underline">Sign up now</a>
<div class="status-name">Please Log In</div> {% endblock %}
<div class="error-message">
You need to be logged in to access this page. Please sign in to continue shopping.
</div>
<div class="action-buttons">
<a href="{{ base_url }}shop/account/login" class="btn btn-primary">Log In</a>
<a href="{{ base_url }}shop/account/register" class="btn btn-secondary">Create Account</a>
</div>
<div class="support-link">
Don't have an account? <a href="{{ base_url }}shop/account/register">Sign up now</a>
</div>
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,33 +1,22 @@
{# app/templates/shop/errors/403.html #}
{# 403 Forbidden error page - access restricted #}
{% extends "shop/errors/base.html" %} {% extends "shop/errors/base.html" %}
{% block icon %}🔒{% endblock %} {% block icon %}🔒{% endblock %}
{% block title %}403 - Access Restricted{% endblock %} {% block title %}403 - Access Restricted{% endblock %}
{% block content %} {% block action_buttons %}
{% if vendor and theme and theme.branding and theme.branding.logo %} <a href="{{ base_url }}shop/account/login"
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
{% endif %} Log In
</a>
<a href="{{ base_url }}shop/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
<div class="error-icon">🔒</div> {% block support_link %}
<div class="status-code">403</div> Need help accessing your account? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact support</a>
<div class="status-name">Access Restricted</div> {% endblock %}
<div class="error-message">
This page requires authentication or special permissions to access. Please log in to continue.
</div>
<div class="action-buttons">
<a href="{{ base_url }}shop/account/login" class="btn btn-primary">Log In</a>
<a href="{{ base_url }}shop/" class="btn btn-secondary">Go to Home</a>
</div>
<div class="support-link">
Need help accessing your account? <a href="{{ base_url }}shop/contact">Contact support</a>
</div>
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,33 +1,22 @@
{# app/templates/shop/errors/404.html #}
{# 404 Not Found error page - uses base template with custom icon and message #}
{% extends "shop/errors/base.html" %} {% extends "shop/errors/base.html" %}
{% block icon %}🔍{% endblock %} {% block icon %}🔍{% endblock %}
{% block title %}404 - Page Not Found{% endblock %} {% block title %}404 - Page Not Found{% endblock %}
{% block content %} {% block action_buttons %}
{% if vendor and theme and theme.branding and theme.branding.logo %} <a href="{{ base_url }}shop/"
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
{% endif %} Continue Shopping
</a>
<a href="{{ base_url }}shop/products"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
View All Products
</a>
{% endblock %}
<div class="error-icon">🔍</div> {% block support_link %}
<div class="status-code">404</div> Can't find what you're looking for? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> and we'll help you find it.
<div class="status-name">Page Not Found</div>
<div class="error-message">
Sorry, we couldn't find the page you're looking for. The product or page may have been moved or is no longer available.
</div>
<div class="action-buttons">
<a href="{{ base_url }}shop/" class="btn btn-primary">Continue Shopping</a>
<a href="{{ base_url }}shop/products" class="btn btn-secondary">View All Products</a>
</div>
<div class="support-link">
Can't find what you're looking for? <a href="{{ base_url }}shop/contact">Contact us</a> and we'll help you find it.
</div>
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,46 +1,35 @@
{# app/templates/shop/errors/422.html #}
{# 422 Unprocessable Entity error page - validation errors #}
{% extends "shop/errors/base.html" %} {% extends "shop/errors/base.html" %}
{% block icon %}📝{% endblock %} {% block icon %}📝{% endblock %}
{% block title %}422 - Invalid Information{% endblock %} {% block title %}422 - Invalid Information{% endblock %}
{% block content %} {% block extra_content %}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
{% endif %}
<div class="error-icon">📝</div>
<div class="status-code">422</div>
<div class="status-name">Please Check Your Information</div>
<div class="error-message">
Some of the information you provided isn't valid. Please review the form and try again.
</div>
{% if details and details.validation_errors %} {% if details and details.validation_errors %}
<div style="margin: 2rem auto; max-width: 400px; text-align: left; background: #fef2f2; padding: 1.5rem; border-radius: 0.75rem; border-left: 4px solid var(--color-primary);"> <div class="my-8 mx-auto max-w-md text-left bg-red-50 p-6 rounded-xl border-l-4 border-theme-primary">
<h3 style="color: var(--color-text); font-size: 0.875rem; margin-bottom: 0.75rem; font-weight: 600;">Please correct:</h3> <h3 class="text-gray-700 text-sm mb-3 font-semibold">Please correct:</h3>
<ul style="list-style: none; padding: 0; margin: 0;"> <ul class="list-none p-0 m-0">
{% for error in details.validation_errors %} {% for error in details.validation_errors %}
<li style="margin-bottom: 0.5rem; color: #7f1d1d; font-size: 0.875rem;"> <li class="mb-2 text-red-800 text-sm">• {{ error.msg }}</li>
• {{ error.msg }}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
{% endblock %}
<div class="action-buttons"> {% block action_buttons %}
<a href="javascript:history.back()" class="btn btn-primary">Go Back and Fix</a> <a href="javascript:history.back()"
<a href="{{ base_url }}shop/" class="btn btn-secondary">Go to Home</a> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
</div> Go Back and Fix
</a>
<a href="{{ base_url }}shop/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
<div class="support-link"> {% block support_link %}
Having trouble? <a href="{{ base_url }}shop/contact">We're here to help</a> Having trouble? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">We're here to help</a>
</div> {% endblock %}
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,41 +1,32 @@
{# app/templates/shop/errors/429.html #}
{# 429 Too Many Requests error page - rate limiting #}
{% extends "shop/errors/base.html" %} {% extends "shop/errors/base.html" %}
{% block icon %}⏱️{% endblock %} {% block icon %}⏱️{% endblock %}
{% block title %}429 - Please Slow Down{% endblock %} {% block title %}429 - Please Slow Down{% endblock %}
{% block content %} {% block extra_content %}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
{% endif %}
<div class="error-icon">⏱️</div>
<div class="status-code">429</div>
<div class="status-name">Please Slow Down</div>
<div class="error-message">
You're browsing a bit too fast! Please wait a moment before continuing.
</div>
{% if details and details.retry_after %} {% if details and details.retry_after %}
<div style="margin: 1.5rem 0; padding: 1rem; background: #fef3c7; border-radius: 0.75rem;"> <div class="my-6 p-4 bg-amber-50 rounded-xl">
<p style="color: #92400e; font-weight: 600;"> <p class="text-amber-800 font-semibold">
Please wait {{ details.retry_after }} seconds Please wait {{ details.retry_after }} seconds
</p> </p>
</div> </div>
{% endif %} {% endif %}
{% endblock %}
<div class="action-buttons"> {% block action_buttons %}
<a href="javascript:location.reload()" class="btn btn-primary">Try Again</a> <a href="javascript:location.reload()"
<a href="{{ base_url }}shop/" class="btn btn-secondary">Go to Home</a> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
</div> Try Again
</a>
<a href="{{ base_url }}shop/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
<div class="support-link"> {% block support_link %}
Questions? <a href="{{ base_url }}shop/contact">Contact us</a> Questions? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
</div> {% endblock %}
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,33 +1,22 @@
{# app/templates/shop/errors/500.html #}
{# 500 Internal Server Error page #}
{% extends "shop/errors/base.html" %} {% extends "shop/errors/base.html" %}
{% block icon %}😔{% endblock %} {% block icon %}😔{% endblock %}
{% block title %}500 - Something Went Wrong{% endblock %} {% block title %}500 - Something Went Wrong{% endblock %}
{% block content %} {% block action_buttons %}
{% if vendor and theme and theme.branding and theme.branding.logo %} <a href="{{ base_url }}shop/"
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
{% endif %} Go to Home
</a>
<a href="javascript:location.reload()"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Try Again
</a>
{% endblock %}
<div class="error-icon">😔</div> {% block support_link %}
<div class="status-code">500</div> Issue persisting? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Let us know</a> and we'll help you out.
<div class="status-name">Oops! Something Went Wrong</div> {% endblock %}
<div class="error-message">
We're experiencing technical difficulties. Our team has been notified and is working to fix the issue. Please try again in a few moments.
</div>
<div class="action-buttons">
<a href="{{ base_url }}shop/" class="btn btn-primary">Go to Home</a>
<a href="javascript:location.reload()" class="btn btn-secondary">Try Again</a>
</div>
<div class="support-link">
Issue persisting? <a href="{{ base_url }}shop/contact">Let us know</a> and we'll help you out.
</div>
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,33 +1,22 @@
{# app/templates/shop/errors/502.html #}
{# 502 Bad Gateway error page - upstream service unavailable #}
{% extends "shop/errors/base.html" %} {% extends "shop/errors/base.html" %}
{% block icon %}🔧{% endblock %} {% block icon %}🔧{% endblock %}
{% block title %}502 - Service Temporarily Unavailable{% endblock %} {% block title %}502 - Service Temporarily Unavailable{% endblock %}
{% block content %} {% block action_buttons %}
{% if vendor and theme and theme.branding and theme.branding.logo %} <a href="javascript:location.reload()"
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
{% endif %} Try Again
</a>
<a href="{{ base_url }}shop/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
<div class="error-icon">🔧</div> {% block support_link %}
<div class="status-code">502</div> If this continues, <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">let us know</a>
<div class="status-name">Temporarily Unavailable</div> {% endblock %}
<div class="error-message">
We're having trouble connecting to our systems. This is usually temporary. Please try again in a few moments.
</div>
<div class="action-buttons">
<a href="javascript:location.reload()" class="btn btn-primary">Try Again</a>
<a href="{{ base_url }}shop/" class="btn btn-secondary">Go to Home</a>
</div>
<div class="support-link">
If this continues, <a href="{{ base_url }}shop/contact">let us know</a>
</div>
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,31 +1,22 @@
{# app/templates/shop/errors/generic.html #}
{# Generic error page - fallback for any error code #}
{% extends "shop/errors/base.html" %} {% extends "shop/errors/base.html" %}
{% block icon %}⚠️{% endblock %} {% block icon %}⚠️{% endblock %}
{% block title %}{{ status_code }} - {{ status_name }}{% endblock %} {% block title %}{{ status_code }} - {{ status_name }}{% endblock %}
{% block content %} {% block action_buttons %}
{% if vendor and theme and theme.branding and theme.branding.logo %} <a href="{{ base_url }}shop/"
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo"> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
{% endif %} Continue Shopping
</a>
<a href="javascript:history.back()"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go Back
</a>
{% endblock %}
<div class="error-icon">⚠️</div> {% block support_link %}
<div class="status-code">{{ status_code }}</div> Need assistance? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
<div class="status-name">{{ status_name }}</div> {% endblock %}
<div class="error-message">{{ message }}</div>
<div class="action-buttons">
<a href="{{ base_url }}shop/" class="btn btn-primary">Continue Shopping</a>
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
</div>
<div class="support-link">
Need assistance? <a href="{{ base_url }}shop/contact">Contact us</a>
</div>
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}

View File

@@ -14,6 +14,7 @@ from app.api.deps import (
get_current_vendor_from_cookie_or_header, get_current_vendor_from_cookie_or_header,
get_current_customer_from_cookie_or_header get_current_customer_from_cookie_or_header
) )
from models.database.customer import Customer
# Admin page # Admin page
@router.get("/admin/dashboard") @router.get("/admin/dashboard")
@@ -25,10 +26,10 @@ def admin_page(user: User = Depends(get_current_admin_from_cookie_or_header)):
def vendor_page(user: User = Depends(get_current_vendor_from_cookie_or_header)): def vendor_page(user: User = Depends(get_current_vendor_from_cookie_or_header)):
pass pass
# Customer page # Customer page - NOTE: Returns Customer, not User!
@router.get("/shop/account/dashboard") @router.get("/shop/account/dashboard")
def customer_page(user: User = Depends(get_current_customer_from_cookie_or_header)): def customer_page(customer: Customer = Depends(get_current_customer_from_cookie_or_header)):
pass pass # customer.id, customer.email, customer.vendor_id
``` ```
### For API Endpoints (header only - better security) ### For API Endpoints (header only - better security)
@@ -39,6 +40,7 @@ from app.api.deps import (
get_current_vendor_api, get_current_vendor_api,
get_current_customer_api get_current_customer_api
) )
from models.database.customer import Customer
# Admin API # Admin API
@router.post("/api/v1/admin/vendors") @router.post("/api/v1/admin/vendors")
@@ -46,14 +48,14 @@ def admin_api(user: User = Depends(get_current_admin_api)):
pass pass
# Vendor API # Vendor API
@router.post("/api/v1/vendor/{code}/products") @router.post("/api/v1/vendor/products")
def vendor_api(user: User = Depends(get_current_vendor_api)): def vendor_api(user: User = Depends(get_current_vendor_api)):
pass pass # user.token_vendor_id for vendor context
# Customer API # Customer API - NOTE: Returns Customer, not User!
@router.post("/api/v1/shop/orders") @router.post("/api/v1/shop/orders")
def customer_api(user: User = Depends(get_current_customer_api)): def customer_api(request: Request, customer: Customer = Depends(get_current_customer_api)):
pass pass # customer.id, request.state.vendor validated to match
``` ```
--- ---

View File

@@ -467,30 +467,51 @@ current_user: User = Depends(get_current_vendor_api)
**Purpose:** Authenticate customer users for HTML pages **Purpose:** Authenticate customer users for HTML pages
**Accepts:** Cookie (`customer_token`) OR Authorization header **Accepts:** Cookie (`customer_token`) OR Authorization header
**Returns:** `Customer` object **Returns:** `Customer` object (from `models.database.customer.Customer`)
**Raises:** **Raises:**
- `InvalidTokenException` - No token or invalid token - `InvalidTokenException` - No token, invalid token, or not a customer token
- `InsufficientPermissionsException` - User is not customer (admin/vendor blocked) - `UnauthorizedVendorAccessException` - Token vendor_id doesn't match URL vendor
**Note:** The `InsufficientPermissionsException` raised here is from `app.exceptions.auth`, which provides general authentication permission checking. This is distinct from `InsufficientTeamPermissionsException` used for team-specific permissions. **Security Features:**
- **Token type validation:** Only accepts tokens with `type: "customer"` - admin and vendor tokens are rejected
- **Vendor validation:** Validates that `token.vendor_id` matches `request.state.vendor.id` (URL-based vendor)
- Prevents cross-vendor token reuse (customer from Vendor A cannot use token on Vendor B's shop)
**Usage:** **Usage:**
```python ```python
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header) from models.database.customer import Customer
customer: Customer = Depends(get_current_customer_from_cookie_or_header)
# Access customer.id, customer.email, customer.vendor_id, etc.
``` ```
#### `get_current_customer_api()` #### `get_current_customer_api()`
**Purpose:** Authenticate customer users for API endpoints **Purpose:** Authenticate customer users for API endpoints
**Accepts:** Authorization header ONLY **Accepts:** Authorization header ONLY
**Returns:** `Customer` object **Returns:** `Customer` object (from `models.database.customer.Customer`)
**Raises:** **Raises:**
- `InvalidTokenException` - No token or invalid token - `InvalidTokenException` - No token, invalid token, or not a customer token
- `InsufficientPermissionsException` - User is not customer (admin/vendor blocked) - `UnauthorizedVendorAccessException` - Token vendor_id doesn't match URL vendor
**Security Features:**
- **Token type validation:** Only accepts tokens with `type: "customer"` - admin and vendor tokens are rejected
- **Vendor validation:** Validates that `token.vendor_id` matches `request.state.vendor.id` (URL-based vendor)
- Prevents cross-vendor token reuse
**Usage:** **Usage:**
```python ```python
current_customer: Customer = Depends(get_current_customer_api) from models.database.customer import Customer
@router.post("/orders")
def place_order(
request: Request,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
# customer is a Customer object, not User
vendor = request.state.vendor # Already validated to match token
order = order_service.create_order(db, vendor.id, customer.id, ...)
``` ```
#### `get_current_user()` #### `get_current_user()`
@@ -581,20 +602,26 @@ async def vendor_login_page(
**Purpose:** Check if customer user is authenticated (without enforcing) **Purpose:** Check if customer user is authenticated (without enforcing)
**Accepts:** Cookie (`customer_token`) OR Authorization header **Accepts:** Cookie (`customer_token`) OR Authorization header
**Returns:** **Returns:**
- `User` object with `role="customer"` if authenticated - `Customer` object if authenticated with valid customer token
- `None` if no token, invalid token, or user is not customer - `None` if no token, invalid token, vendor mismatch, or not a customer token
**Raises:** Never raises exceptions **Raises:** Never raises exceptions
**Security Features:**
- Only accepts tokens with `type: "customer"` - admin and vendor tokens return `None`
- Validates vendor_id in token matches URL vendor - mismatch returns `None`
**Usage:** **Usage:**
```python ```python
from models.database.customer import Customer
# Shop login page redirect # Shop login page redirect
@router.get("/shop/account/login") @router.get("/shop/account/login")
async def customer_login_page( async def customer_login_page(
request: Request, request: Request,
current_user: Optional[User] = Depends(get_current_customer_optional) customer: Customer | None = Depends(get_current_customer_optional)
): ):
if current_user: if customer:
# User already logged in, redirect to account page # Customer already logged in, redirect to account page
return RedirectResponse(url="/shop/account", status_code=302) return RedirectResponse(url="/shop/account", status_code=302)
# Not logged in, show login form # Not logged in, show login form