feat(merchant): extract merchant portal as first-class frontend with auth, Tailwind fixes, and Gitea CI
Some checks failed
CI / ruff (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / architecture (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / audit (push) Has been cancelled
CI / docs (push) Has been cancelled

- Extract login/dashboard from billing module into core (matching admin pattern)
- Add merchant auth API with path-isolated cookies (path=/merchants)
- Add merchant base layout with sidebar/header partials and Alpine.js init
- Add frontend detection and login redirect for MERCHANT type
- Wire merchant token in shared api-client.js (get/clear)
- Migrate billing templates to merchant base with dark mode support
- Fix Tailwind: rename shop→storefront in sources and config
- DRY Makefile tailwind targets with TAILWIND_FRONTENDS loop
- Rebuild all Tailwind outputs (production minified)
- Add Gitea Actions CI workflow (ruff, pytest, architecture, docs)
- Add Gitea deployment documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 20:25:29 +01:00
parent ecb5309879
commit 0437af67ec
31 changed files with 1925 additions and 780 deletions

View File

@@ -2,9 +2,9 @@
"""
Tenancy module merchant API routes.
Provides merchant-facing API endpoints for the merchant portal:
- /account/stores - List merchant's stores
- /account/profile - Get/update merchant profile
Aggregates all merchant tenancy routes:
- /auth/* - Merchant authentication (login, logout, /me)
- /account/* - Merchant account management (stores, profile)
Auto-discovered by the route system (merchant.py in routes/api/).
"""
@@ -21,13 +21,17 @@ from app.core.database import get_db
from app.modules.tenancy.models import Merchant
from models.schema.auth import UserContext
from .merchant_auth import merchant_auth_router
logger = logging.getLogger(__name__)
router = APIRouter()
ROUTE_CONFIG = {
"prefix": "/account",
}
# Include auth routes (/auth/login, /auth/logout, /auth/me)
router.include_router(merchant_auth_router, tags=["merchant-auth"])
# Account routes are defined below with /account prefix
_account_router = APIRouter(prefix="/account")
# ============================================================================
@@ -81,11 +85,11 @@ def _get_user_merchant(db: Session, user_context: UserContext) -> Merchant:
# ============================================================================
# ENDPOINTS
# ACCOUNT ENDPOINTS
# ============================================================================
@router.get("/stores")
@_account_router.get("/stores")
async def merchant_stores(
request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
@@ -114,7 +118,7 @@ async def merchant_stores(
return {"stores": stores}
@router.get("/profile")
@_account_router.get("/profile")
async def merchant_profile(
request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
@@ -140,7 +144,7 @@ async def merchant_profile(
}
@router.put("/profile")
@_account_router.put("/profile")
async def update_merchant_profile(
request: Request,
profile_data: MerchantProfileUpdate,
@@ -177,3 +181,7 @@ async def update_merchant_profile(
"tax_number": merchant.tax_number,
"is_verified": merchant.is_verified,
}
# Include account routes in main router
router.include_router(_account_router, tags=["merchant-account"])

View File

@@ -0,0 +1,117 @@
# app/modules/tenancy/routes/api/merchant_auth.py
"""
Merchant authentication endpoints.
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/merchants (restricted to merchant routes only)
- Returns token in response for localStorage (API calls)
This prevents merchant cookies from being sent to admin or store routes.
"""
import logging
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from app.api.deps import get_current_merchant_from_cookie_or_header
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.modules.core.services.auth_service import auth_service
from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse, UserContext
merchant_auth_router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@merchant_auth_router.post("/login", response_model=LoginResponse)
def merchant_login(
user_credentials: UserLogin, response: Response, db: Session = Depends(get_db)
):
"""
Merchant login endpoint.
Only allows users who own at least one active merchant to login.
Returns JWT token for authenticated merchant users.
Sets token in two places:
1. HTTP-only cookie with path=/merchants (for browser page navigation)
2. Response body (for localStorage and API calls)
The cookie is restricted to /merchants/* routes only to prevent
it from being sent to admin or store routes.
"""
# Authenticate user and verify merchant ownership
login_result = auth_service.login_merchant(db=db, user_credentials=user_credentials)
logger.info(f"Merchant login successful: {login_result['user'].username}")
# Set HTTP-only cookie for browser navigation
# CRITICAL: path=/merchants restricts cookie to merchant routes only
response.set_cookie(
key="merchant_token",
value=login_result["token_data"]["access_token"],
httponly=True, # JavaScript cannot access (XSS protection)
secure=should_use_secure_cookies(), # HTTPS only in production/staging
samesite="lax", # CSRF protection
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
path="/merchants", # RESTRICTED TO MERCHANT ROUTES ONLY
)
logger.debug(
f"Set merchant_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path=/merchants, httponly=True, secure={should_use_secure_cookies()})"
)
# Also return token in response for localStorage (API calls)
return LoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"],
user=login_result["user"],
)
@merchant_auth_router.get("/me", response_model=UserResponse)
def get_current_merchant(
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
):
"""
Get current authenticated merchant user.
This endpoint validates the token and ensures the user owns merchants.
Returns the current user's information.
Token can come from:
- Authorization header (API calls)
- merchant_token cookie (browser navigation, path=/merchants only)
"""
logger.info(f"Merchant user info requested: {current_user.username}")
return current_user
@merchant_auth_router.post("/logout", response_model=LogoutResponse)
def merchant_logout(response: Response):
"""
Merchant logout endpoint.
Clears the merchant_token cookie.
Client should also remove token from localStorage.
"""
logger.info("Merchant logout")
# Clear the cookie (must match path used when setting)
response.delete_cookie(
key="merchant_token",
path="/merchants",
)
# Also clear legacy cookie with path=/ (from before path isolation was added)
response.delete_cookie(
key="merchant_token",
path="/",
)
logger.debug("Deleted merchant_token cookies (both /merchants and / paths)")
return LogoutResponse(message="Logged out successfully")