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(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
customer_token: str | None = Cookie(None),
db: Session = Depends(get_db),
):
def _validate_customer_token(token: str, request: Request, db: Session):
"""
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.
Note: Public shop pages (/shop/products, etc.) don't use this dependency.
Priority:
1. Authorization header (API calls)
2. customer_token cookie (page navigation)
Validates:
1. Token signature and expiration
2. Token type is "customer"
3. Customer exists and is active
4. Token vendor_id matches request vendor (URL-based)
Args:
request: FastAPI request
credentials: Optional Bearer token from header
customer_token: Optional token from customer_token cookie
token: JWT token string
request: FastAPI request (for vendor context)
db: Database session
Returns:
Customer: Authenticated customer object
Raises:
InvalidTokenException: If no token or invalid token
InvalidTokenException: If token is invalid or expired
UnauthorizedVendorAccessException: If vendor mismatch
"""
from datetime import datetime
@@ -362,14 +356,6 @@ def get_current_customer_from_cookie_or_header(
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
try:
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}")
raise InvalidTokenException("Token has expired")
# Get vendor_id from token for validation
token_vendor_id = payload.get("vendor_id")
except JWTError as e:
logger.warning(f"JWT decode error: {str(e)}")
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}")
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})")
return customer
def get_current_customer_api(
credentials: HTTPAuthorizationCredentials = Depends(security),
def get_current_customer_from_cookie_or_header(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
customer_token: str | None = Cookie(None),
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:
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
db: Database session
Returns:
User: Authenticated customer user
Customer: Authenticated customer object
Raises:
InvalidTokenException: If no token or invalid token
InsufficientPermissionsException: If user is not customer (admin/vendor blocked)
UnauthorizedVendorAccessException: If vendor mismatch
"""
if not credentials:
raise InvalidTokenException("Authorization header required for API calls")
user = _validate_user_token(credentials.credentials, 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
return _validate_customer_token(credentials.credentials, request, db)
# ============================================================================
@@ -883,9 +915,9 @@ def get_current_customer_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
customer_token: str | None = Cookie(None),
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.
Used for login pages to check if user is already authenticated.
@@ -901,8 +933,8 @@ def get_current_customer_optional(
db: Database session
Returns:
User: Authenticated customer user if valid token exists
None: If no token, invalid token, or user is not customer
Customer: Authenticated customer if valid token exists
None: If no token, invalid token, or vendor mismatch
"""
token, source = _get_token_from_request(
credentials, customer_token, "customer_token", str(request.url.path)
@@ -912,14 +944,8 @@ def get_current_customer_optional(
return None
try:
# Validate token and get user
user = _validate_user_token(token, db)
# Verify user is customer
if user.role == "customer":
return user
# Validate customer token (includes vendor validation)
return _validate_customer_token(token, request, db)
except Exception:
# Invalid token or other error
pass
return None
# Invalid token, vendor mismatch, or other error
return None

View File

@@ -1,23 +1,26 @@
# 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).
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
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
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 models.database.customer import Customer
from models.database.user import User
from models.schema.order import (
OrderCreate,
OrderDetailResponse,
@@ -29,47 +32,11 @@ router = APIRouter()
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)
def place_order(
request: Request,
order_data: OrderCreate,
current_user: User = Depends(get_current_customer_api),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
@@ -82,17 +49,11 @@ def place_order(
Request Body:
- 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)
if not vendor:
raise HTTPException(
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)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] place_order for customer {customer.id}",
@@ -100,7 +61,6 @@ def place_order(
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
"user_id": current_user.id,
},
)
@@ -132,7 +92,7 @@ def get_my_orders(
request: Request,
skip: int = Query(0, ge=0),
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),
):
"""
@@ -145,17 +105,11 @@ def get_my_orders(
- skip: Number of orders to skip (pagination)
- 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)
if not vendor:
raise HTTPException(
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)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] get_my_orders for customer {customer.id}",
@@ -185,7 +139,7 @@ def get_my_orders(
def get_order_details(
request: Request,
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),
):
"""
@@ -197,17 +151,11 @@ def get_order_details(
Path Parameters:
- 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)
if not vendor:
raise HTTPException(
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)
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
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" %}
{% block icon %}❌{% endblock %}
{% block title %}400 - Invalid Request{% endblock %}
{% block content %}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
{% endif %}
{% block action_buttons %}
<a href="javascript:history.back()"
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">
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>
<div class="status-code">400</div>
<div class="status-name">Invalid Request</div>
<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 %}
{% block support_link %}
Need help? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %}

View File

@@ -1,33 +1,22 @@
{# app/templates/shop/errors/401.html #}
{# 401 Unauthorized error page - prompts login #}
{% extends "shop/errors/base.html" %}
{% block icon %}🔐{% endblock %}
{% block title %}401 - Authentication Required{% endblock %}
{% block content %}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
{% endif %}
{% block action_buttons %}
<a href="{{ base_url }}shop/account/login"
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">
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>
<div class="status-code">401</div>
<div class="status-name">Please Log In</div>
<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 %}
{% block support_link %}
Don't have an account? <a href="{{ base_url }}shop/account/register" class="text-theme-primary font-semibold hover:underline">Sign up now</a>
{% endblock %}

View File

@@ -1,33 +1,22 @@
{# app/templates/shop/errors/403.html #}
{# 403 Forbidden error page - access restricted #}
{% extends "shop/errors/base.html" %}
{% block icon %}🔒{% endblock %}
{% block title %}403 - Access Restricted{% endblock %}
{% block content %}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
{% endif %}
{% block action_buttons %}
<a href="{{ base_url }}shop/account/login"
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">
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>
<div class="status-code">403</div>
<div class="status-name">Access Restricted</div>
<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 %}
{% block support_link %}
Need help accessing your account? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact support</a>
{% 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" %}
{% block icon %}🔍{% endblock %}
{% block title %}404 - Page Not Found{% endblock %}
{% block content %}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
{% endif %}
{% block action_buttons %}
<a href="{{ base_url }}shop/"
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">
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>
<div class="status-code">404</div>
<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 %}
{% block support_link %}
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.
{% 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" %}
{% block icon %}📝{% endblock %}
{% block title %}422 - Invalid Information{% endblock %}
{% block 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>
{% block extra_content %}
{% 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);">
<h3 style="color: var(--color-text); font-size: 0.875rem; margin-bottom: 0.75rem; font-weight: 600;">Please correct:</h3>
<ul style="list-style: none; padding: 0; margin: 0;">
<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 class="text-gray-700 text-sm mb-3 font-semibold">Please correct:</h3>
<ul class="list-none p-0 m-0">
{% for error in details.validation_errors %}
<li style="margin-bottom: 0.5rem; color: #7f1d1d; font-size: 0.875rem;">
• {{ error.msg }}
</li>
<li class="mb-2 text-red-800 text-sm">• {{ error.msg }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}
<div class="action-buttons">
<a href="javascript:history.back()" class="btn btn-primary">Go Back and Fix</a>
<a href="{{ base_url }}shop/" class="btn btn-secondary">Go to Home</a>
</div>
{% block action_buttons %}
<a href="javascript:history.back()"
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">
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">
Having trouble? <a href="{{ base_url }}shop/contact">We're here to help</a>
</div>
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}
{% block support_link %}
Having trouble? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">We're here to help</a>
{% 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" %}
{% block icon %}⏱️{% endblock %}
{% block title %}429 - Please Slow Down{% endblock %}
{% block 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>
{% block extra_content %}
{% if details and details.retry_after %}
<div style="margin: 1.5rem 0; padding: 1rem; background: #fef3c7; border-radius: 0.75rem;">
<p style="color: #92400e; font-weight: 600;">
<div class="my-6 p-4 bg-amber-50 rounded-xl">
<p class="text-amber-800 font-semibold">
Please wait {{ details.retry_after }} seconds
</p>
</div>
{% endif %}
{% endblock %}
<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>
{% block action_buttons %}
<a href="javascript:location.reload()"
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">
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">
Questions? <a href="{{ base_url }}shop/contact">Contact us</a>
</div>
{% if vendor %}
<div class="vendor-info">
{{ vendor.name }}
</div>
{% endif %}
{% endblock %}
{% block support_link %}
Questions? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %}

View File

@@ -1,33 +1,22 @@
{# app/templates/shop/errors/500.html #}
{# 500 Internal Server Error page #}
{% extends "shop/errors/base.html" %}
{% block icon %}😔{% endblock %}
{% block title %}500 - Something Went Wrong{% endblock %}
{% block content %}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
{% endif %}
{% block action_buttons %}
<a href="{{ base_url }}shop/"
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">
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>
<div class="status-code">500</div>
<div class="status-name">Oops! Something Went Wrong</div>
<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 %}
{% block support_link %}
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.
{% 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" %}
{% block icon %}🔧{% endblock %}
{% block title %}502 - Service Temporarily Unavailable{% endblock %}
{% block content %}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
{% endif %}
{% block action_buttons %}
<a href="javascript:location.reload()"
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">
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>
<div class="status-code">502</div>
<div class="status-name">Temporarily Unavailable</div>
<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 %}
{% block support_link %}
If this continues, <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">let us know</a>
{% 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" %}
{% block icon %}⚠️{% endblock %}
{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}
{% block content %}
{% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo">
{% endif %}
{% block action_buttons %}
<a href="{{ base_url }}shop/"
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">
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>
<div class="status-code">{{ status_code }}</div>
<div class="status-name">{{ status_name }}</div>
<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 %}
{% block support_link %}
Need assistance? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %}

View File

@@ -14,6 +14,7 @@ from app.api.deps import (
get_current_vendor_from_cookie_or_header,
get_current_customer_from_cookie_or_header
)
from models.database.customer import Customer
# Admin page
@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)):
pass
# Customer page
# Customer page - NOTE: Returns Customer, not User!
@router.get("/shop/account/dashboard")
def customer_page(user: User = Depends(get_current_customer_from_cookie_or_header)):
pass
def customer_page(customer: Customer = Depends(get_current_customer_from_cookie_or_header)):
pass # customer.id, customer.email, customer.vendor_id
```
### For API Endpoints (header only - better security)
@@ -39,6 +40,7 @@ from app.api.deps import (
get_current_vendor_api,
get_current_customer_api
)
from models.database.customer import Customer
# Admin API
@router.post("/api/v1/admin/vendors")
@@ -46,14 +48,14 @@ def admin_api(user: User = Depends(get_current_admin_api)):
pass
# 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)):
pass
pass # user.token_vendor_id for vendor context
# Customer API
# Customer API - NOTE: Returns Customer, not User!
@router.post("/api/v1/shop/orders")
def customer_api(user: User = Depends(get_current_customer_api)):
pass
def customer_api(request: Request, customer: Customer = Depends(get_current_customer_api)):
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
**Accepts:** Cookie (`customer_token`) OR Authorization header
**Returns:** `Customer` object
**Returns:** `Customer` object (from `models.database.customer.Customer`)
**Raises:**
- `InvalidTokenException` - No token or invalid token
- `InsufficientPermissionsException` - User is not customer (admin/vendor blocked)
- `InvalidTokenException` - No token, invalid token, or not a customer token
- `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:**
```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()`
**Purpose:** Authenticate customer users for API endpoints
**Accepts:** Authorization header ONLY
**Returns:** `Customer` object
**Returns:** `Customer` object (from `models.database.customer.Customer`)
**Raises:**
- `InvalidTokenException` - No token or invalid token
- `InsufficientPermissionsException` - User is not customer (admin/vendor blocked)
- `InvalidTokenException` - No token, invalid token, or not a customer token
- `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:**
```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()`
@@ -581,20 +602,26 @@ async def vendor_login_page(
**Purpose:** Check if customer user is authenticated (without enforcing)
**Accepts:** Cookie (`customer_token`) OR Authorization header
**Returns:**
- `User` object with `role="customer"` if authenticated
- `None` if no token, invalid token, or user is not customer
- `Customer` object if authenticated with valid customer token
- `None` if no token, invalid token, vendor mismatch, or not a customer token
**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:**
```python
from models.database.customer import Customer
# Shop login page redirect
@router.get("/shop/account/login")
async def customer_login_page(
request: Request,
current_user: Optional[User] = Depends(get_current_customer_optional)
customer: Customer | None = Depends(get_current_customer_optional)
):
if current_user:
# User already logged in, redirect to account page
if customer:
# Customer already logged in, redirect to account page
return RedirectResponse(url="/shop/account", status_code=302)
# Not logged in, show login form