fix: loyalty module end-to-end — merchant route, store menus, sidebar, API error handling
Some checks failed
Some checks failed
- Add merchant loyalty overview route and template (was 404)
- Fix store loyalty route paths to match menu URLs (/{store_code}/loyalty/...)
- Add loyalty rewards card to storefront account dashboard
- Fix merchant overview to resolve merchant via get_merchant_for_current_user_page
- Fix store login to use store's primary platform for JWT token (interim fix)
- Fix apiClient to attach status/errorCode to thrown errors (fixes error.status
checks in 12+ JS files — loyalty settings, terminal, email templates, etc.)
- Hide "Add Product" sidebar button when catalog module is not enabled
- Add proposal doc for proper platform detection in store login flow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{% if 'loyalty' in enabled_modules %}
|
||||||
|
<!-- Loyalty Rewards Card -->
|
||||||
|
<a href="{{ base_url }}account/loyalty"
|
||||||
|
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||||
|
x-data="{ points: null, loaded: false }"
|
||||||
|
x-init="fetch('/api/v1/storefront/loyalty/card').then(r => r.json()).then(d => { if (d.card) { points = d.card.points_balance; } loaded = true; }).catch(() => { loaded = true; })">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('gift', 'h-8 w-8')"></span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Loyalty Rewards</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">View your points & rewards</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<template x-if="loaded && points !== null">
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)" x-text="points.toLocaleString()"></p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Points Balance</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="loaded && points === null">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Join our rewards program</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Messages Card -->
|
<!-- Messages Card -->
|
||||||
<a href="{{ base_url }}account/messages"
|
<a href="{{ base_url }}account/messages"
|
||||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||||
|
|||||||
100
app/modules/loyalty/routes/pages/merchant.py
Normal file
100
app/modules/loyalty/routes/pages/merchant.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# app/modules/loyalty/routes/pages/merchant.py
|
||||||
|
"""
|
||||||
|
Loyalty Merchant Page Routes (HTML rendering).
|
||||||
|
|
||||||
|
Merchant portal pages for:
|
||||||
|
- Loyalty overview (aggregate stats across all stores)
|
||||||
|
|
||||||
|
Authentication: merchant_token cookie or Authorization header.
|
||||||
|
|
||||||
|
Auto-discovered by the route system (merchant.py in routes/pages/ triggers
|
||||||
|
registration under /merchants/loyalty/*).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import (
|
||||||
|
get_current_merchant_from_cookie_or_header,
|
||||||
|
get_merchant_for_current_user_page,
|
||||||
|
)
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.core.utils.page_context import get_context_for_frontend
|
||||||
|
from app.modules.enums import FrontendType
|
||||||
|
from app.modules.loyalty.services import program_service
|
||||||
|
from app.modules.tenancy.models import Merchant
|
||||||
|
from app.templates_config import templates
|
||||||
|
from models.schema.auth import UserContext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ROUTE_CONFIG = {
|
||||||
|
"prefix": "/loyalty",
|
||||||
|
}
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helper
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _get_merchant_context(
|
||||||
|
request: Request,
|
||||||
|
db: Session,
|
||||||
|
current_user: UserContext,
|
||||||
|
**extra_context,
|
||||||
|
) -> dict:
|
||||||
|
"""Build template context for merchant loyalty pages."""
|
||||||
|
return get_context_for_frontend(
|
||||||
|
FrontendType.MERCHANT,
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
user=current_user,
|
||||||
|
**extra_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LOYALTY OVERVIEW
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overview", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def merchant_loyalty_overview(
|
||||||
|
request: Request,
|
||||||
|
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||||
|
merchant: Merchant = Depends(get_merchant_for_current_user_page),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render merchant loyalty overview page.
|
||||||
|
|
||||||
|
Shows aggregate loyalty program stats across all merchant stores.
|
||||||
|
"""
|
||||||
|
# Get merchant stats server-side
|
||||||
|
merchant_id = merchant.id
|
||||||
|
stats = {}
|
||||||
|
try:
|
||||||
|
stats = program_service.get_merchant_stats(db, merchant_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to load loyalty stats for merchant {merchant_id}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = _get_merchant_context(
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
loyalty_stats=stats,
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"loyalty/merchant/overview.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
@@ -7,12 +7,15 @@ Store pages for:
|
|||||||
- Loyalty members management
|
- Loyalty members management
|
||||||
- Program settings
|
- Program settings
|
||||||
- Stats dashboard
|
- Stats dashboard
|
||||||
|
|
||||||
|
Routes follow the standard store convention: /{store_code}/loyalty/...
|
||||||
|
so they match the menu URLs in definition.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Path, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
||||||
@@ -27,8 +30,9 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Route configuration for module route discovery
|
# Route configuration for module route discovery
|
||||||
|
# No custom prefix — routes include /loyalty/ in their paths to follow
|
||||||
|
# the standard store convention: /store/{store_code}/loyalty/...
|
||||||
ROUTE_CONFIG = {
|
ROUTE_CONFIG = {
|
||||||
"prefix": "/loyalty",
|
|
||||||
"tags": ["store-loyalty"],
|
"tags": ["store-loyalty"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,13 +80,37 @@ def get_store_context(
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LOYALTY ROOT (Redirect to Terminal)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{store_code}/loyalty",
|
||||||
|
response_class=RedirectResponse,
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
async def store_loyalty_root(
|
||||||
|
store_code: str = Path(..., description="Store code"),
|
||||||
|
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Redirect loyalty root to the terminal (primary daily interface).
|
||||||
|
Menu item "Dashboard" points here.
|
||||||
|
"""
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/store/{store_code}/loyalty/terminal",
|
||||||
|
status_code=302,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# LOYALTY TERMINAL (Primary Daily Interface)
|
# LOYALTY TERMINAL (Primary Daily Interface)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/terminal",
|
"/{store_code}/loyalty/terminal",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
@@ -108,7 +136,7 @@ async def store_loyalty_terminal(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/cards",
|
"/{store_code}/loyalty/cards",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
@@ -129,7 +157,7 @@ async def store_loyalty_cards(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/cards/{card_id}",
|
"/{store_code}/loyalty/cards/{card_id}",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
@@ -156,7 +184,7 @@ async def store_loyalty_card_detail(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/settings",
|
"/{store_code}/loyalty/settings",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
@@ -182,7 +210,7 @@ async def store_loyalty_settings(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/stats",
|
"/{store_code}/loyalty/stats",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
@@ -208,7 +236,7 @@ async def store_loyalty_stats(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{store_code}/enroll",
|
"/{store_code}/loyalty/enroll",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
|
|||||||
116
app/modules/loyalty/templates/loyalty/merchant/overview.html
Normal file
116
app/modules/loyalty/templates/loyalty/merchant/overview.html
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{# app/modules/loyalty/templates/loyalty/merchant/overview.html #}
|
||||||
|
{% extends "merchant/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Loyalty Overview{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="merchantLoyaltyOverview()">
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-8 mt-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Loyalty Overview</h2>
|
||||||
|
<p class="mt-1 text-gray-500 dark:text-gray-400">Loyalty program statistics across all your stores.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Program State -->
|
||||||
|
<template x-if="!stats.program_id && !loading">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||||
|
<span x-html="$icon('gift', 'w-12 h-12 mx-auto text-gray-400')"></span>
|
||||||
|
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No Loyalty Program</h3>
|
||||||
|
<p class="mt-2 text-gray-500 dark:text-gray-400">
|
||||||
|
Your loyalty program hasn't been set up yet. Contact the platform administrator or set it up from your store dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<template x-if="stats.program_id || loading">
|
||||||
|
<div>
|
||||||
|
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<!-- Total Cards -->
|
||||||
|
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||||
|
<span x-html="$icon('identification', 'w-5 h-5')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Total Cards</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_cards || 0"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Cards -->
|
||||||
|
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||||
|
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Active Cards</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active_cards || 0"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Points Issued (30d) -->
|
||||||
|
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||||
|
<span x-html="$icon('arrow-trending-up', 'w-5 h-5')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Points Issued (30d)</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="(stats.points_issued_30d || 0).toLocaleString()"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transactions (30d) -->
|
||||||
|
<div class="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||||
|
<span x-html="$icon('receipt-percent', 'w-5 h-5')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Transactions (30d)</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.transactions_30d || 0"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All Time Stats -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">All-Time Statistics</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Points Issued</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900 dark:text-white" x-text="(stats.total_points_issued || 0).toLocaleString()"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Points Redeemed</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900 dark:text-white" x-text="(stats.total_points_redeemed || 0).toLocaleString()"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Points Redeemed (30d)</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900 dark:text-white" x-text="(stats.points_redeemed_30d || 0).toLocaleString()"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Outstanding Liability</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900 dark:text-white"
|
||||||
|
x-text="'€' + ((stats.estimated_liability_cents || 0) / 100).toFixed(2)"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
function merchantLoyaltyOverview() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
stats: {{ loyalty_stats | tojson }},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -111,10 +111,27 @@ def store_login(
|
|||||||
f"for store {store.store_code} as {store_role}"
|
f"for store {store.store_code} as {store_role}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get platform from URL context (middleware-detected)
|
# Resolve platform from the store's primary platform link.
|
||||||
platform = get_current_platform(request)
|
# Middleware-detected platform is unreliable for API paths on localhost
|
||||||
platform_id = platform.id if platform else None
|
# (e.g., /api/v1/store/auth/login defaults to "main" instead of the store's platform).
|
||||||
platform_code = platform.code if platform else None
|
platform_id = None
|
||||||
|
platform_code = None
|
||||||
|
if store:
|
||||||
|
from app.modules.core.services.menu_service import menu_service
|
||||||
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
|
||||||
|
primary_pid = menu_service.get_store_primary_platform_id(db, store.id)
|
||||||
|
if primary_pid:
|
||||||
|
plat = platform_service.get_platform_by_id(db, primary_pid)
|
||||||
|
if plat:
|
||||||
|
platform_id = plat.id
|
||||||
|
platform_code = plat.code
|
||||||
|
|
||||||
|
if platform_id is None:
|
||||||
|
# Fallback to middleware-detected platform
|
||||||
|
platform = get_current_platform(request)
|
||||||
|
platform_id = platform.id if platform else None
|
||||||
|
platform_code = platform.code if platform else None
|
||||||
|
|
||||||
# Create store-scoped access token with store information
|
# Create store-scoped access token with store information
|
||||||
token_data = auth_service.auth_manager.create_access_token(
|
token_data = auth_service.auth_manager.create_access_token(
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if 'catalog' in enabled_modules %}
|
||||||
<!-- Quick Actions (static, outside dynamic menu) -->
|
<!-- Quick Actions (static, outside dynamic menu) -->
|
||||||
<div class="px-6 my-6">
|
<div class="px-6 my-6">
|
||||||
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 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"
|
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 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"
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
<span class="ml-2">Add Product</span>
|
<span class="ml-2">Add Product</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|||||||
52
docs/proposals/store-login-platform-detection.md
Normal file
52
docs/proposals/store-login-platform-detection.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Store Login: JWT Token Gets Wrong Platform
|
||||||
|
|
||||||
|
**Status:** Open — needs design review on fallback strategy
|
||||||
|
**Date:** 2026-02-24
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When a user logs in to a store via `/platforms/loyalty/store/FASHIONHUB/login`, the JWT token should encode `platform_id=3` (loyalty). Instead, it gets `platform_id=2` (main). This causes the store sidebar menu to show the wrong modules — e.g., analytics (from "main") appears while loyalty (from "loyalty") is missing.
|
||||||
|
|
||||||
|
### Root Cause Chain
|
||||||
|
|
||||||
|
1. User visits `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login`
|
||||||
|
2. PlatformContextMiddleware detects `loyalty` and rewrites path to `/store/FASHIONHUB/login` — **correct**
|
||||||
|
3. Login page renders with loyalty platform context — **correct**
|
||||||
|
4. JavaScript `login.js` POSTs to `apiClient.post('/store/auth/login', ...)` which resolves to `/api/v1/store/auth/login` — **no platform prefix**
|
||||||
|
5. PlatformContextMiddleware sees `/api/v1/store/auth/login`, doesn't match `/platforms/*`, defaults to "main" platform
|
||||||
|
6. Store auth endpoint calls `get_current_platform(request)` → gets "main" (id=2) instead of "loyalty" (id=3)
|
||||||
|
7. Token encodes `platform_id=2`, all subsequent menu/API calls use the wrong platform
|
||||||
|
|
||||||
|
The Referer-based platform extraction in the middleware (`middleware/platform_context.py` lines 359-374) only handles `/api/v1/storefront/` paths, not `/api/v1/store/` paths.
|
||||||
|
|
||||||
|
### Why `is_primary` Is Wrong
|
||||||
|
|
||||||
|
A store can be subscribed to multiple platforms. The platform should be determined by the login URL context (which platform the user navigated from), not by a database default. Using `is_primary` would always pick the same platform regardless of how the user accessed the store.
|
||||||
|
|
||||||
|
## Key Constraint
|
||||||
|
|
||||||
|
- **Production:** One domain per platform (e.g., `omsflow.lu` for OMS, `loyaltyflow.lu` for loyalty). Store subdomains: `fashionhub.omsflow.lu`. Premium domains: `fashionhub.lu`.
|
||||||
|
- **Development:** Path-based: `/platforms/{code}/store/{store_code}/login`
|
||||||
|
- A store can be on multiple platforms and should show different menus depending on which platform URL the user logged in from.
|
||||||
|
|
||||||
|
## Current Workaround
|
||||||
|
|
||||||
|
`app/modules/tenancy/routes/api/store_auth.py` currently uses `is_primary` to resolve the platform from the store's `store_platforms` table. This works for single-platform stores but breaks for multi-platform stores.
|
||||||
|
|
||||||
|
## Files Involved
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `middleware/platform_context.py` | Platform detection from URL/domain — doesn't cover `/api/v1/store/` paths |
|
||||||
|
| `middleware/store_context.py` | Store detection from URL/domain |
|
||||||
|
| `app/modules/tenancy/routes/api/store_auth.py` | Store login endpoint — creates JWT with platform_id |
|
||||||
|
| `app/modules/tenancy/static/store/js/login.js` | Frontend login — POSTs to `/api/v1/store/auth/login` |
|
||||||
|
| `static/shared/js/api-client.js` | API client — base URL is `/api/v1` (no platform prefix) |
|
||||||
|
| `models/schema/auth.py` | `UserLogin` schema — currently has `store_code` but not `platform_code` |
|
||||||
|
| `app/modules/core/routes/api/store_menu.py` | Menu API — reads `token_platform_id` from JWT |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- What should the fallback strategy be when platform can't be determined from the login context?
|
||||||
|
- Should the solution also handle storefront customer login (which has the same issue)?
|
||||||
|
- Should the Referer-based detection in `platform_context.py` be extended to cover `/api/v1/store/` paths as a complementary fix?
|
||||||
@@ -150,7 +150,10 @@ class APIClient {
|
|||||||
|
|
||||||
const errorMessage = data.message || data.detail || 'Unauthorized - please login again';
|
const errorMessage = data.message || data.detail || 'Unauthorized - please login again';
|
||||||
apiLog.error('Throwing authentication error:', errorMessage);
|
apiLog.error('Throwing authentication error:', errorMessage);
|
||||||
throw new Error(errorMessage);
|
const authError = new Error(errorMessage);
|
||||||
|
authError.status = response.status;
|
||||||
|
authError.errorCode = data.error_code;
|
||||||
|
throw authError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle non-OK responses
|
// Handle non-OK responses
|
||||||
@@ -161,7 +164,10 @@ class APIClient {
|
|||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
errorCode: data.error_code
|
errorCode: data.error_code
|
||||||
});
|
});
|
||||||
throw new Error(errorMessage);
|
const apiError = new Error(errorMessage);
|
||||||
|
apiError.status = response.status;
|
||||||
|
apiError.errorCode = data.error_code;
|
||||||
|
throw apiError;
|
||||||
}
|
}
|
||||||
|
|
||||||
apiLog.info('Request completed successfully');
|
apiLog.info('Request completed successfully');
|
||||||
|
|||||||
Reference in New Issue
Block a user