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

View File

@@ -0,0 +1,141 @@
{# app/modules/tenancy/templates/tenancy/merchant/login.html #}
{# Standalone login page - does NOT extend merchant/base.html #}
<!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="merchantLogin()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Merchant Login - Wizamart</title>
<!-- Fonts: Local fallback + Google Fonts -->
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='merchant/css/tailwind.output.css') }}" />
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="flex flex-col overflow-y-auto md:flex-row">
<div class="h-32 md:h-auto md:w-1/2">
<img aria-hidden="true" class="object-cover w-full h-full dark:hidden"
src="{{ url_for('static', path='admin/img/login-office.jpeg') }}" alt="Office" />
<img aria-hidden="true" class="hidden object-cover w-full h-full dark:block"
src="{{ url_for('static', path='admin/img/login-office-dark.jpeg') }}" alt="Office" />
</div>
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Merchant Login
</h1>
<!-- Alert Messages -->
<div x-show="error" x-text="error"
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
x-transition></div>
<div x-show="success" x-text="success"
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
x-transition></div>
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email</span>
<input x-model="credentials.email"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.email }"
placeholder="you@example.com"
autocomplete="username"
required />
<span x-show="errors.email" x-text="errors.email"
class="text-xs text-red-600 dark:text-red-400"></span>
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.password }"
placeholder="***************"
type="password"
autocomplete="current-password"
required />
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400"></span>
</label>
{# noqa: FE-002 - Inline spinner SVG for loading state #}
<button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Sign in</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
</span>
</button>
</form>
<hr class="my-8" />
<p class="mt-4">
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
href="#">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
&larr; Back to Platform
</a>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Scripts - ORDER MATTERS! -->
<!-- 1. Log Configuration -->
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<!-- 2. Icons -->
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<!-- 3. Utils -->
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<!-- 4. API Client -->
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<!-- 5. Alpine.js v3 with CDN fallback -->
<script>
(function() {
var script = document.createElement('script');
script.defer = true;
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js';
script.onerror = function() {
console.warn('Alpine.js CDN failed, loading local copy...');
var fallbackScript = document.createElement('script');
fallbackScript.defer = true;
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/alpine.min.js") }}';
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
})();
</script>
<!-- 6. Merchant Login Logic -->
<script src="{{ url_for('core_static', path='merchant/js/login.js') }}"></script>
</body>
</html>