fix: storefront login 403, cookie path, double-storefront URLs, and auth redirects
Some checks failed
CI / ruff (push) Successful in 9s
CI / pytest (push) Failing after 46m52s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Extract store/platform context from Referer header for storefront API requests
  (StoreContextMiddleware and PlatformContextMiddleware) so login POST works in
  dev mode where API paths lack /platforms/{code}/ prefix
- Set customer token cookie path to "/" for cross-route compatibility
- Fix double storefront in URLs: replace {{ base_url }}storefront/ with {{ base_url }}
  across all 24 storefront templates
- Fix auth error redirect to include platform prefix and use store_code
- Update seed script to output correct storefront login URLs
- Add 20 new unit tests covering all fixes; fix 9 pre-existing test failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:29:52 +01:00
parent 32e4aa6564
commit f47c680cb8
38 changed files with 759 additions and 165 deletions

View File

@@ -434,14 +434,19 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
base_url = "/"
if access_method == "path" and store:
full_prefix = (
store_context.get("full_prefix", "/store/")
if store_context
else "/store/"
)
base_url = f"{full_prefix}{store.subdomain}/"
platform = getattr(request.state, "platform", None)
platform_original_path = getattr(request.state, "platform_original_path", None)
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
else:
full_prefix = (
store_context.get("full_prefix", "/storefront/")
if store_context
else "/storefront/"
)
base_url = f"{full_prefix}{store.store_code}/"
login_url = f"{base_url}storefront/account/login"
login_url = f"{base_url}account/login"
logger.debug(f"Redirecting to {login_url}")
return RedirectResponse(url=login_url, status_code=302)
# Fallback to root for unknown contexts (PLATFORM)

View File

@@ -352,7 +352,7 @@
x-transition:leave="transform transition ease-in-out duration-300"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="w-screen max-w-lg"
class="w-screen max-w-lg h-full"
>
<div class="flex h-full flex-col bg-white dark:bg-gray-800 shadow-xl">
<!-- Header -->

View File

@@ -14,7 +14,7 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}storefront/products" class="hover:text-primary">Products</a>
<a href="{{ base_url }}products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">Cart</span>
</div>
@@ -40,7 +40,7 @@
<p class="text-gray-600 dark:text-gray-400 mb-6">
Add some products to get started!
</p>
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors">
<a href="{{ base_url }}products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors">
Browse Products
</a>
</div>
@@ -154,7 +154,7 @@
Proceed to Checkout
</button>
<a href="{{ base_url }}storefront/products" class="block w-full px-6 py-3 text-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<a href="{{ base_url }}products" class="block w-full px-6 py-3 text-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
Continue Shopping
</a>
@@ -309,9 +309,9 @@ document.addEventListener('alpine:init', () => {
if (!token) {
// Redirect to login with return URL
window.location.href = '{{ base_url }}storefront/account/login?return={{ base_url }}storefront/checkout';
window.location.href = '{{ base_url }}account/login?return={{ base_url }}checkout';
} else {
window.location.href = '{{ base_url }}storefront/checkout';
window.location.href = '{{ base_url }}checkout';
}
}
};

View File

@@ -13,7 +13,7 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}storefront/products" class="hover:text-primary">Products</a>
<a href="{{ base_url }}products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="categoryName">{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}</span>
</div>
@@ -61,14 +61,14 @@
<div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}storefront/products/${product.id}`">
<a :href="`{{ base_url }}products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}storefront/products/${product.id}`" class="block">
<a :href="`{{ base_url }}products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
@@ -99,7 +99,7 @@
<p class="text-gray-600 dark:text-gray-400 mb-4">
Check back later or browse other categories.
</p>
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
<a href="{{ base_url }}products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
Browse All Products
</a>
</div>

View File

@@ -12,7 +12,7 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}storefront/products" class="hover:text-primary">Products</a>
<a href="{{ base_url }}products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="product?.marketplace_product?.title || 'Product'">Product</span>
</div>
@@ -186,7 +186,7 @@
<div class="product-grid">
<template x-for="related in relatedProducts" :key="related.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer">
<a :href="`{{ base_url }}storefront/products/${related.id}`">
<a :href="`{{ base_url }}products/${related.id}`">
<img
:src="related.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
@@ -195,7 +195,7 @@
>
</a>
<div class="p-4">
<a :href="`{{ base_url }}storefront/products/${related.id}`">
<a :href="`{{ base_url }}products/${related.id}`">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="related.marketplace_product?.title"></h3>
</a>
<p class="text-2xl font-bold text-primary">
@@ -301,7 +301,7 @@ document.addEventListener('alpine:init', () => {
this.showToast('Failed to load product', 'error');
// Redirect back to products after error
setTimeout(() => {
window.location.href = '{{ base_url }}storefront/products';
window.location.href = '{{ base_url }}products';
}, 2000);
} finally {
this.loading = false;

View File

@@ -73,14 +73,14 @@
<div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}storefront/products/${product.id}`">
<a :href="`{{ base_url }}products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}storefront/products/${product.id}`" class="block">
<a :href="`{{ base_url }}products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>

View File

@@ -80,14 +80,14 @@
<div x-show="!loading && query && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}storefront/products/${product.id}`">
<a :href="`{{ base_url }}products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}storefront/products/${product.id}`" class="block">
<a :href="`{{ base_url }}products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>

View File

@@ -13,7 +13,7 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}storefront/account/dashboard" class="hover:text-primary">Account</a>
<a href="{{ base_url }}account/dashboard" class="hover:text-primary">Account</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">Wishlist</span>
</div>
@@ -46,7 +46,7 @@
<p class="text-gray-600 dark:text-gray-400 mb-6">
Log in to your account to view and manage your wishlist.
</p>
<a href="{{ base_url }}storefront/account/login" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
<a href="{{ base_url }}account/login" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
Log In
</a>
</div>
@@ -64,14 +64,14 @@
<span x-html="$icon('heart', 'w-5 h-5 text-red-500 fill-current')"></span>
</button>
<a :href="`{{ base_url }}storefront/products/${item.product.id}`">
<a :href="`{{ base_url }}products/${item.product.id}`">
<img loading="lazy" :src="item.product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="item.product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}storefront/products/${item.product.id}`" class="block">
<a :href="`{{ base_url }}products/${item.product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="item.product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="item.product.marketplace_product?.description"></p>
@@ -122,7 +122,7 @@
<p class="text-gray-600 dark:text-gray-400 mb-6">
Save items you like by clicking the heart icon on product pages.
</p>
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
<a href="{{ base_url }}products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
Browse Products
</a>
</div>

View File

@@ -11,10 +11,10 @@
{# Breadcrumbs #}
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li><a href="{{ base_url }}storefront/" class="hover:text-primary">Home</a></li>
<li><a href="{{ base_url }}" class="hover:text-primary">Home</a></li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
<a href="{{ base_url }}storefront/cart" class="hover:text-primary">Cart</a>
<a href="{{ base_url }}cart" class="hover:text-primary">Cart</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -36,7 +36,7 @@
<span class="mx-auto h-12 w-12 text-gray-400 mb-4 block" x-html="$icon('shopping-bag', 'h-12 w-12 mx-auto')"></span>
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">Your cart is empty</h3>
<p class="text-gray-500 dark:text-gray-400 mb-6">Add some products before checking out.</p>
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
<a href="{{ base_url }}products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
Browse Products
</a>
</div>
@@ -900,7 +900,7 @@ function checkoutPage() {
console.log('[CHECKOUT] Order placed:', order.order_number);
// Redirect to confirmation page
window.location.href = '{{ base_url }}storefront/order-confirmation?order=' + order.order_number;
window.location.href = '{{ base_url }}order-confirmation?order=' + order.order_number;
} catch (error) {
console.error('[CHECKOUT] Error placing order:', error);

View File

@@ -36,7 +36,7 @@
{# CTA Button #}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
Browse Our Shop
@@ -71,7 +71,7 @@
Explore
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}storefront/products"
<a href="{{ base_url }}products"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">🛍️</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
@@ -84,7 +84,7 @@
{% if header_pages %}
{% for page in header_pages[:2] %}
<a href="{{ base_url }}storefront/{{ page.slug }}"
<a href="{{ base_url }}{{ page.slug }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📄</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
@@ -96,7 +96,7 @@
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}storefront/about"
<a href="{{ base_url }}about"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4"></div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
@@ -107,7 +107,7 @@
</p>
</a>
<a href="{{ base_url }}storefront/contact"
<a href="{{ base_url }}contact"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📧</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">

View File

@@ -43,7 +43,7 @@
{% endif %}
<div class="flex flex-col sm:flex-row gap-4">
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg"
style="background-color: var(--color-primary)">
Shop Now
@@ -174,7 +174,7 @@
Explore More
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}storefront/products"
<a href="{{ base_url }}products"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 dark:from-primary/20 dark:to-primary/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4">🛍️</div>
@@ -190,7 +190,7 @@
{% if header_pages %}
{% for page in header_pages[:2] %}
<a href="{{ base_url }}storefront/{{ page.slug }}"
<a href="{{ base_url }}{{ page.slug }}"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/10 to-accent/5 dark:from-accent/20 dark:to-accent/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4">📄</div>
@@ -205,7 +205,7 @@
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}storefront/about"
<a href="{{ base_url }}about"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/10 to-accent/5 dark:from-accent/20 dark:to-accent/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4"></div>
@@ -219,7 +219,7 @@
<div class="absolute inset-0 bg-accent opacity-0 group-hover:opacity-5 transition-opacity"></div>
</a>
<a href="{{ base_url }}storefront/contact"
<a href="{{ base_url }}contact"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 dark:from-primary/20 dark:to-primary/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4">📧</div>
@@ -246,7 +246,7 @@
<p class="text-xl mb-10 opacity-90">
Join thousands of satisfied customers today
</p>
<a href="{{ base_url }}storefront/products"
<a href="{{ base_url }}products"
class="inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-primary bg-white hover:bg-gray-50 transition-all transform hover:scale-105 shadow-2xl">
View All Products
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>

View File

@@ -37,7 +37,7 @@
{# Single CTA #}
<div>
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="inline-flex items-center justify-center px-10 py-5 text-xl font-semibold rounded-full text-white bg-primary hover:bg-primary-dark transition-all transform hover:scale-105 shadow-2xl"
style="background-color: var(--color-primary)">
Enter Shop
@@ -49,11 +49,11 @@
{% if header_pages or footer_pages %}
<div class="mt-16 pt-8 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-wrap justify-center gap-6 text-sm">
<a href="{{ base_url }}storefront/products" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
<a href="{{ base_url }}products" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
Products
</a>
{% for page in (header_pages or footer_pages)[:4] %}
<a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
<a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
{{ page.title }}
</a>
{% endfor %}

View File

@@ -40,7 +40,7 @@
{# CTAs #}
<div class="flex flex-col sm:flex-row gap-6 justify-center animate-fade-in animation-delay-400">
<a href="{{ base_url }}storefront/"
<a href="{{ base_url }}"
class="group inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-white bg-primary hover:bg-primary-dark transition-all transform hover:scale-105 shadow-2xl hover:shadow-3xl"
style="background-color: var(--color-primary)">
<span>Start Shopping</span>
@@ -137,7 +137,7 @@
<p class="text-xl mb-10 opacity-90">
Explore our collection and find what you're looking for
</p>
<a href="{{ base_url }}storefront/products"
<a href="{{ base_url }}products"
class="inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-primary bg-white hover:bg-gray-50 transition-all transform hover:scale-105 shadow-2xl">
Browse Products
<span class="w-5 h-5 ml-2" x-html="$icon('chevron-right', 'w-5 h-5')"></span>

View File

@@ -178,23 +178,8 @@ def customer_login(
},
)
# Calculate cookie path based on store access method
store_context = getattr(request.state, "store_context", None)
access_method = (
store_context.get("detection_method", "unknown")
if store_context
else "unknown"
)
cookie_path = "/storefront"
if access_method == "path":
full_prefix = (
store_context.get("full_prefix", "/store/")
if store_context
else "/store/"
)
cookie_path = f"{full_prefix}{store.subdomain}/storefront"
# Set cookie with path=/ so it's sent with all requests
# (platform prefix varies between dev and prod, broad path avoids mismatch)
response.set_cookie(
key="customer_token",
value=login_result["token_data"]["access_token"],
@@ -202,12 +187,12 @@ def customer_login(
secure=should_use_secure_cookies(),
samesite="lax",
max_age=login_result["token_data"]["expires_in"],
path=cookie_path,
path="/",
)
logger.debug(
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path={cookie_path}, httponly=True, secure={should_use_secure_cookies()})",
f"(path=/, httponly=True, secure={should_use_secure_cookies()})",
)
return CustomerLoginResponse(
@@ -237,25 +222,9 @@ def customer_logout(request: Request, response: Response):
},
)
store_context = getattr(request.state, "store_context", None)
access_method = (
store_context.get("detection_method", "unknown")
if store_context
else "unknown"
)
response.delete_cookie(key="customer_token", path="/")
cookie_path = "/storefront"
if access_method == "path" and store:
full_prefix = (
store_context.get("full_prefix", "/store/")
if store_context
else "/store/"
)
cookie_path = f"{full_prefix}{store.subdomain}/storefront"
response.delete_cookie(key="customer_token", path=cookie_path)
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
logger.debug("Deleted customer_token cookie (path=/)")
return LogoutResponse(message="Logged out successfully")

View File

@@ -153,14 +153,19 @@ async def shop_account_root(request: Request):
base_url = "/"
if access_method == "path" and store:
full_prefix = (
store_context.get("full_prefix", "/store/")
if store_context
else "/store/"
)
base_url = f"{full_prefix}{store.subdomain}/"
platform = getattr(request.state, "platform", None)
platform_original_path = getattr(request.state, "platform_original_path", None)
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
else:
full_prefix = (
store_context.get("full_prefix", "/storefront/")
if store_context
else "/storefront/"
)
base_url = f"{full_prefix}{store.store_code}/"
return RedirectResponse(url=f"{base_url}storefront/account/dashboard", status_code=302)
return RedirectResponse(url=f"{base_url}account/dashboard", status_code=302)
# ============================================================================

View File

@@ -400,7 +400,7 @@ function addressesPage() {
if (!response.ok) {
if (response.status === 401) {
window.location.href = '{{ base_url }}storefront/account/login';
window.location.href = '{{ base_url }}account/login';
return;
}
throw new Error('Failed to load addresses');

View File

@@ -18,7 +18,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Orders Card -->
<a href="{{ base_url }}storefront/account/orders"
<a href="{{ base_url }}account/orders"
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">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
@@ -36,7 +36,7 @@
</a>
<!-- Profile Card -->
<a href="{{ base_url }}storefront/account/profile"
<a href="{{ base_url }}account/profile"
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">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
@@ -53,7 +53,7 @@
</a>
<!-- Addresses Card -->
<a href="{{ base_url }}storefront/account/addresses"
<a href="{{ base_url }}account/addresses"
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">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
@@ -67,7 +67,7 @@
</a>
<!-- Messages Card -->
<a href="{{ base_url }}storefront/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"
x-data="{ unreadCount: 0 }"
x-init="fetch('/api/v1/storefront/messages/unread-count').then(r => r.json()).then(d => unreadCount = d.unread_count).catch(() => {})">
@@ -157,14 +157,14 @@ function accountDashboard() {
// Redirect to login page
setTimeout(() => {
window.location.href = '{{ base_url }}storefront/account/login';
window.location.href = '{{ base_url }}account/login';
}, 500);
} else {
console.error('Logout failed with status:', response.status);
this.showToast('Logout failed', 'error');
// Still redirect on failure (cookie might be deleted)
setTimeout(() => {
window.location.href = '{{ base_url }}storefront/account/login';
window.location.href = '{{ base_url }}account/login';
}, 1000);
}
})
@@ -173,7 +173,7 @@ function accountDashboard() {
this.showToast('Logout failed', 'error');
// Redirect anyway
setTimeout(() => {
window.location.href = '{{ base_url }}storefront/account/login';
window.location.href = '{{ base_url }}account/login';
}, 1000);
});
}

View File

@@ -143,13 +143,13 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}storefront/account/login">
href="{{ base_url }}account/login">
Sign in
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}storefront/">
href="{{ base_url }}">
← Continue shopping
</a>
</p>

View File

@@ -127,7 +127,7 @@
style="color: var(--color-primary);">
<span class="ml-2 text-gray-700 dark:text-gray-400">Remember me</span>
</label>
<a href="{{ base_url }}storefront/account/forgot-password"
<a href="{{ base_url }}account/forgot-password"
class="text-sm font-medium hover:underline"
style="color: var(--color-primary);">
Forgot password?
@@ -150,13 +150,13 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Don't have an account?</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}storefront/account/register">
href="{{ base_url }}account/register">
Create an account
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}storefront/">
href="{{ base_url }}">
← Continue shopping
</a>
</p>
@@ -263,7 +263,7 @@
// Redirect to account page or return URL
setTimeout(() => {
const returnUrl = new URLSearchParams(window.location.search).get('return') || '{{ base_url }}storefront/account';
const returnUrl = new URLSearchParams(window.location.search).get('return') || '{{ base_url }}account';
window.location.href = returnUrl;
}, 1000);

View File

@@ -11,7 +11,7 @@
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}storefront/account/dashboard" class="hover:text-primary">My Account</a>
<a href="{{ base_url }}account/dashboard" class="hover:text-primary">My Account</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -330,7 +330,7 @@ function shopProfilePage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
@@ -344,7 +344,7 @@ function shopProfilePage() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load profile');
@@ -380,7 +380,7 @@ function shopProfilePage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login';
window.location.href = '{{ base_url }}account/login';
return;
}
@@ -429,7 +429,7 @@ function shopProfilePage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login';
window.location.href = '{{ base_url }}account/login';
return;
}
@@ -472,7 +472,7 @@ function shopProfilePage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login';
window.location.href = '{{ base_url }}account/login';
return;
}

View File

@@ -218,7 +218,7 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Already have an account?</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}storefront/account/login">
href="{{ base_url }}account/login">
Sign in instead
</a>
</p>
@@ -360,7 +360,7 @@
// Redirect to login after 2 seconds
setTimeout(() => {
window.location.href = '{{ base_url }}storefront/account/login?registered=true';
window.location.href = '{{ base_url }}account/login?registered=true';
}, 2000);
} catch (error) {

View File

@@ -80,7 +80,7 @@
Please request a new password reset link.
</p>
<a href="{{ base_url }}storefront/account/forgot-password"
<a href="{{ base_url }}account/forgot-password"
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
Request New Link
</a>
@@ -164,7 +164,7 @@
You can now sign in with your new password.
</p>
<a href="{{ base_url }}storefront/account/login"
<a href="{{ base_url }}account/login"
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
Sign In
</a>
@@ -177,13 +177,13 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}storefront/account/login">
href="{{ base_url }}account/login">
Sign in
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}storefront/">
href="{{ base_url }}">
← Continue shopping
</a>
</p>

View File

@@ -9,7 +9,7 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header -->
<div class="mb-8">
<a href="{{ base_url }}storefront/account/dashboard" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<a href="{{ base_url }}account/dashboard" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Account
</a>
@@ -26,7 +26,7 @@
<span x-html="$icon('gift', 'w-16 h-16 mx-auto text-gray-300 dark:text-gray-600')"></span>
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Join Our Rewards Program!</h2>
<p class="mt-2 text-gray-600 dark:text-gray-400">Earn points on every purchase and redeem for rewards.</p>
<a href="{{ base_url }}storefront/loyalty/join"
<a href="{{ base_url }}loyalty/join"
class="mt-6 inline-flex items-center px-6 py-3 text-sm font-medium text-white rounded-lg"
style="background-color: var(--color-primary)">
<span x-html="$icon('plus', 'w-5 h-5 mr-2')"></span>
@@ -125,7 +125,7 @@
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Activity</h2>
<a href="{{ base_url }}storefront/account/loyalty/history"
<a href="{{ base_url }}account/loyalty/history"
class="text-sm font-medium hover:underline" style="color: var(--color-primary)">
View All
</a>

View File

@@ -68,12 +68,12 @@
<!-- Actions -->
<div class="space-y-3">
<a href="{{ base_url }}storefront/account/loyalty"
<a href="{{ base_url }}account/loyalty"
class="block w-full py-3 px-4 text-white font-semibold rounded-lg transition-colors text-center"
style="background-color: var(--color-primary)">
View My Loyalty Dashboard
</a>
<a href="{{ base_url }}storefront"
<a href="{{ base_url }}"
class="block w-full py-3 px-4 text-gray-700 dark:text-gray-300 font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-center">
Continue Shopping
</a>

View File

@@ -9,7 +9,7 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header -->
<div class="mb-8">
<a href="{{ base_url }}storefront/account/loyalty" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<a href="{{ base_url }}account/loyalty" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Loyalty
</a>

View File

@@ -13,7 +13,7 @@
<nav class="flex mb-4" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="{{ base_url }}storefront/account/dashboard"
<a href="{{ base_url }}account/dashboard"
class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary dark:text-gray-400 dark:hover:text-white"
style="--hover-color: var(--color-primary)">
<span class="w-4 h-4 mr-2" x-html="$icon('home', 'w-4 h-4')"></span>
@@ -292,7 +292,7 @@ function shopMessages() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
@@ -313,7 +313,7 @@ function shopMessages() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load conversations');
@@ -335,7 +335,7 @@ function shopMessages() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
@@ -357,7 +357,7 @@ function shopMessages() {
});
// Update URL without reload
const url = `{{ base_url }}storefront/account/messages/${conversationId}`;
const url = `{{ base_url }}account/messages/${conversationId}`;
history.pushState({}, '', url);
} catch (error) {
console.error('Error loading conversation:', error);
@@ -404,7 +404,7 @@ function shopMessages() {
this.loadConversations();
// Update URL
history.pushState({}, '', '{{ base_url }}storefront/account/messages');
history.pushState({}, '', '{{ base_url }}account/messages');
},
async sendReply() {
@@ -415,7 +415,7 @@ function shopMessages() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}

View File

@@ -11,11 +11,11 @@
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}storefront/account/dashboard" class="hover:text-primary">My Account</a>
<a href="{{ base_url }}account/dashboard" class="hover:text-primary">My Account</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
<a href="{{ base_url }}storefront/account/orders" class="hover:text-primary">Orders</a>
<a href="{{ base_url }}account/orders" class="hover:text-primary">Orders</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -36,7 +36,7 @@
<div class="ml-3">
<h3 class="text-lg font-medium text-red-800 dark:text-red-200">Error loading order</h3>
<p class="mt-1 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
<a href="{{ base_url }}storefront/account/orders"
<a href="{{ base_url }}account/orders"
class="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700">
Back to Orders
</a>
@@ -314,7 +314,7 @@
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
If you have any questions about your order, please contact us.
</p>
<a href="{{ base_url }}storefront/account/messages"
<a href="{{ base_url }}account/messages"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
style="background-color: var(--color-primary)">
<span class="h-4 w-4 mr-2" x-html="$icon('chat-bubble-left', 'h-4 w-4')"></span>
@@ -326,7 +326,7 @@
<!-- Back Button -->
<div class="mt-8">
<a href="{{ base_url }}storefront/account/orders"
<a href="{{ base_url }}account/orders"
class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-primary">
<span class="h-4 w-4 mr-2" x-html="$icon('chevron-left', 'h-4 w-4')"></span>
Back to Orders
@@ -375,7 +375,7 @@ function shopOrderDetailPage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
@@ -389,7 +389,7 @@ function shopOrderDetailPage() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
if (response.status === 404) {
@@ -501,7 +501,7 @@ function shopOrderDetailPage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
@@ -516,7 +516,7 @@ function shopOrderDetailPage() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
if (response.status === 404) {

View File

@@ -11,7 +11,7 @@
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}storefront/account/dashboard" class="hover:text-primary">My Account</a>
<a href="{{ base_url }}account/dashboard" class="hover:text-primary">My Account</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -45,7 +45,7 @@
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('shopping-bag', 'h-12 w-12 mx-auto')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No orders yet</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">Start shopping to see your orders here.</p>
<a href="{{ base_url }}storefront/products"
<a href="{{ base_url }}products"
class="mt-6 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark transition-colors"
style="background-color: var(--color-primary)">
Browse Products
@@ -79,7 +79,7 @@
<span x-text="getStatusLabel(order.status)"></span>
</span>
<!-- View Details Button -->
<a :href="'{{ base_url }}storefront/account/orders/' + order.id"
<a :href="'{{ base_url }}account/orders/' + order.id"
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
View Details
<span class="ml-2 h-4 w-4" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -167,7 +167,7 @@ function shopOrdersPage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
@@ -182,7 +182,7 @@ function shopOrdersPage() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load orders');

View File

@@ -319,10 +319,12 @@ class PlatformContextMiddleware:
path = scope["path"]
host = ""
referer = ""
for header_name, header_value in scope.get("headers", []):
if header_name == b"host":
host = header_value.decode("utf-8")
break
elif header_name == b"referer":
referer = header_value.decode("utf-8")
# Skip for static files
if self._is_static_file(path):
@@ -354,6 +356,23 @@ class PlatformContextMiddleware:
# Detect platform context
platform_context = self._detect_platform_context(path, host)
# For storefront API requests on localhost, the path doesn't contain
# /platforms/{code}/, so extract platform from the Referer header instead.
# e.g., Referer: http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/...
host_without_port = host.split(":")[0] if ":" in host else host
if (
host_without_port in _LOCAL_HOSTS
and path.startswith("/api/v1/storefront/")
and referer
and platform_context
and platform_context.get("detection_method") == "default"
):
referer_platform = self._extract_platform_from_referer(referer)
if referer_platform:
# Keep the original API path — don't rewrite to the Referer's path
referer_platform["clean_path"] = path
platform_context = referer_platform
if platform_context:
db_gen = get_db()
db = next(db_gen)
@@ -488,6 +507,43 @@ class PlatformContextMiddleware:
return None
@staticmethod
def _extract_platform_from_referer(referer: str) -> dict | None:
"""
Extract platform context from Referer header.
Used for storefront API requests on localhost where the API path
doesn't contain /platforms/{code}/ but the Referer does.
e.g., Referer: http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/...
→ platform_code = "loyalty"
"""
try:
from urllib.parse import urlparse
parsed = urlparse(referer)
referer_path = parsed.path or ""
if referer_path.startswith("/platforms/"):
path_after = referer_path[11:] # Remove "/platforms/"
parts = path_after.split("/", 1)
platform_code = parts[0].lower()
if platform_code:
logger.debug(
f"[PLATFORM] Extracted platform from Referer: {platform_code}"
)
return {
"path_prefix": platform_code,
"detection_method": "path",
"host": parsed.hostname or "",
"original_path": referer_path,
"clean_path": "/" + parts[1] if len(parts) > 1 and parts[1] else "/",
}
except Exception as e:
logger.warning(f"[PLATFORM] Failed to extract platform from Referer: {e}")
return None
def _is_static_file(self, path: str) -> bool:
"""Check if path is for static files."""
path_lower = path.lower()

View File

@@ -444,8 +444,42 @@ class StoreContextMiddleware(BaseHTTPMiddleware):
request.state.clean_path = request.url.path
return await call_next(request)
# Skip store detection for API routes (admin API, store API have store_id in URL)
# For API routes: skip most, but handle storefront API via Referer
if StoreContextManager.is_api_request(request):
# Storefront API requests need store context from the Referer header
# (the page URL contains the store code, e.g. /storefront/FASHIONHUB/...)
if request.url.path.startswith("/api/v1/storefront/"):
referer_context = StoreContextManager.extract_store_from_referer(request)
if referer_context:
db_gen = get_db()
db = next(db_gen)
try:
store = StoreContextManager.get_store_from_context(db, referer_context)
request.state.store = store
request.state.store_context = referer_context
request.state.clean_path = request.url.path
if store:
logger.debug(
"[STORE] Store detected for storefront API via Referer",
extra={
"store_id": store.id,
"store_name": store.name,
"path": request.url.path,
},
)
finally:
db.close()
return await call_next(request)
logger.debug(
f"[STORE] No Referer store context for storefront API: {request.url.path}",
extra={"path": request.url.path},
)
request.state.store = None
request.state.store_context = None
request.state.clean_path = request.url.path
return await call_next(request)
# Non-storefront API routes: skip store detection
logger.debug(
f"[STORE] Skipping store detection for non-storefront API: {request.url.path}",
extra={"path": request.url.path, "reason": "api"},

View File

@@ -1382,9 +1382,10 @@ def print_summary(db: Session):
print(f" {store.name} ({store.store_code}):")
if platform_codes:
for pc in platform_codes:
print(f" [{pc}] Storefront: {base}/platforms/{pc}/storefront/{store.store_code}/")
print(f" [{pc}] Dashboard: {base}/platforms/{pc}/store/{store.store_code}/")
print(f" [{pc}] Login: {base}/platforms/{pc}/store/{store.store_code}/login")
print(f" [{pc}] Storefront: {base}/platforms/{pc}/storefront/{store.store_code}/")
print(f" [{pc}] Customer login: {base}/platforms/{pc}/storefront/{store.store_code}/account/login")
print(f" [{pc}] Store dashboard: {base}/platforms/{pc}/store/{store.store_code}/")
print(f" [{pc}] Staff login: {base}/platforms/{pc}/store/{store.store_code}/login")
else:
print(" (!) No platform assigned")
print()
@@ -1397,7 +1398,7 @@ def print_summary(db: Session):
print(f" 3. Merchant panel: {base}/merchants/login")
print(f" 4. Store panel: {base}/platforms/oms/store/WIZATECH/login")
print(f" 5. Storefront: {base}/platforms/oms/storefront/WIZATECH/")
print(f" 6. Customer login: {base}/platforms/oms/storefront/WIZATECH/account")
print(f" 6. Customer login: {base}/platforms/oms/storefront/WIZATECH/account/login")
# =============================================================================

View File

@@ -0,0 +1,44 @@
# tests/unit/api/test_storefront_auth.py
"""
Unit tests for storefront auth cookie handling.
Tests that customer_token cookie is set with path='/' so it works
across all URL patterns (dev mode with /platforms/ prefix, prod mode with subdomains).
"""
import pytest
@pytest.mark.unit
class TestCustomerTokenCookiePath:
"""Verify cookie path is set correctly for cross-routing compatibility."""
def test_login_sets_cookie_with_root_path(self):
"""
The customer_token cookie must use path='/' to work with all URL patterns.
Previously the cookie path was calculated as '/storefront/{subdomain}/storefront'
which didn't match the actual page URLs (/platforms/{code}/storefront/{store_code}/...).
"""
import inspect
from app.modules.customers.routes.api.storefront import customer_login
source = inspect.getsource(customer_login)
# Verify the cookie is set with path="/"
assert 'path="/"' in source or "path='/'" in source, (
"customer_login must set cookie with path='/'. "
"Other paths like '/storefront' or '/storefront/{subdomain}/storefront' "
"don't match dev mode URLs (/platforms/{code}/storefront/{store_code}/...)"
)
def test_logout_deletes_cookie_with_root_path(self):
"""The customer_token cookie must be deleted with path='/' to match the set path."""
import inspect
from app.modules.customers.routes.api.storefront import customer_logout
source = inspect.getsource(customer_logout)
assert 'path="/"' in source or "path='/'" in source, (
"customer_logout must delete cookie with path='/' to match how it was set"
)

View File

@@ -29,7 +29,9 @@ class TestFrontendTypeMiddleware:
request = Mock(spec=Request)
request.url = Mock(path="/admin/dashboard")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/admin/dashboard", store=None)
request.state = Mock(spec=[])
request.state.platform_clean_path = "/admin/dashboard"
request.state.store = None
call_next = AsyncMock(return_value=Mock())
@@ -47,7 +49,9 @@ class TestFrontendTypeMiddleware:
request = Mock(spec=Request)
request.url = Mock(path="/store/settings")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/store/settings", store=None)
request.state = Mock(spec=[])
request.state.platform_clean_path = "/store/settings"
request.state.store = None
call_next = AsyncMock(return_value=Mock())
@@ -64,7 +68,9 @@ class TestFrontendTypeMiddleware:
request = Mock(spec=Request)
request.url = Mock(path="/storefront/products")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/storefront/products", store=None)
request.state = Mock(spec=[])
request.state.platform_clean_path = "/storefront/products"
request.state.store = None
call_next = AsyncMock(return_value=Mock())
@@ -83,7 +89,9 @@ class TestFrontendTypeMiddleware:
request.headers = {"host": "orion.omsflow.lu"}
mock_store = Mock()
mock_store.name = "Test Store"
request.state = Mock(clean_path="/products", store=mock_store)
request.state = Mock(spec=[])
request.state.platform_clean_path = "/products"
request.state.store = mock_store
call_next = AsyncMock(return_value=Mock())
@@ -100,7 +108,9 @@ class TestFrontendTypeMiddleware:
request = Mock(spec=Request)
request.url = Mock(path="/pricing")
request.headers = {"host": "localhost"}
request.state = Mock(clean_path="/pricing", store=None)
request.state = Mock(spec=[])
request.state.platform_clean_path = "/pricing"
request.state.store = None
call_next = AsyncMock(return_value=Mock())
@@ -127,21 +137,23 @@ class TestFrontendTypeMiddleware:
assert response is expected_response
@pytest.mark.asyncio
async def test_middleware_uses_clean_path_when_available(self):
"""Test middleware uses clean_path when available."""
async def test_middleware_uses_platform_clean_path_when_available(self):
"""Test middleware uses platform_clean_path when available."""
middleware = FrontendTypeMiddleware(app=None)
request = Mock(spec=Request)
request.url = Mock(path="/stores/orion/store/settings")
request.url = Mock(path="/platforms/main/store/settings")
request.headers = {"host": "localhost"}
# clean_path shows the rewritten path
request.state = Mock(clean_path="/store/settings", store=None)
# platform_clean_path shows the path with platform prefix stripped
request.state = Mock(spec=[])
request.state.platform_clean_path = "/store/settings"
request.state.store = None
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
# Should detect as STORE based on clean_path
# Should detect as STORE based on platform_clean_path
assert request.state.frontend_type == FrontendType.STORE
@pytest.mark.asyncio

View File

@@ -132,6 +132,7 @@ class TestLanguageDispatchStore:
mock_resolve.assert_called_once_with(
user_preferred="de",
store_dashboard="fr",
cookie_language=None,
)
assert request.state.language == "de"
@@ -154,6 +155,7 @@ class TestLanguageDispatchStore:
mock_resolve.assert_called_once_with(
user_preferred=None,
store_dashboard=None,
cookie_language=None,
)
@@ -356,6 +358,7 @@ class TestLanguageHelpers:
max_age=60 * 60 * 24 * 365,
httponly=False,
samesite="lax",
path="/",
)
assert result is response
@@ -372,7 +375,7 @@ class TestLanguageHelpers:
response = Mock(spec=Response)
result = delete_language_cookie(response)
response.delete_cookie.assert_called_once_with(key="lang")
response.delete_cookie.assert_called_once_with(key="lang", path="/")
assert result is response
def test_get_user_language_from_token_with_pref(self):

View File

@@ -995,3 +995,88 @@ class TestURLRoutingSummary:
assert context["detection_method"] == "domain"
assert context["domain"] == "omsflow.lu"
# clean_path not set for domain detection - uses original path
@pytest.mark.unit
@pytest.mark.middleware
class TestExtractPlatformFromReferer:
"""Tests for Referer-based platform detection for storefront API requests."""
def test_extract_platform_from_referer_with_platforms_prefix(self):
"""Extract platform code from Referer with /platforms/{code}/ path."""
middleware = PlatformContextMiddleware(app=None)
result = middleware._extract_platform_from_referer(
"http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/login"
)
assert result is not None
assert result["path_prefix"] == "loyalty"
assert result["detection_method"] == "path"
def test_extract_platform_from_referer_no_platforms_prefix(self):
"""Referer without /platforms/ returns None."""
middleware = PlatformContextMiddleware(app=None)
result = middleware._extract_platform_from_referer(
"http://localhost:8000/storefront/FASHIONHUB/products"
)
assert result is None
def test_extract_platform_from_referer_empty_string(self):
"""Empty referer returns None."""
middleware = PlatformContextMiddleware(app=None)
result = middleware._extract_platform_from_referer("")
assert result is None
def test_extract_platform_from_referer_oms_platform(self):
"""Extract OMS platform from Referer."""
middleware = PlatformContextMiddleware(app=None)
result = middleware._extract_platform_from_referer(
"http://localhost:8000/platforms/oms/store/WIZATECH/dashboard"
)
assert result is not None
assert result["path_prefix"] == "oms"
@pytest.mark.unit
@pytest.mark.middleware
class TestPlatformContextMiddlewareReferer:
"""Test PlatformContextMiddleware __call__ with Referer-based platform detection."""
@pytest.mark.asyncio
async def test_middleware_storefront_api_uses_referer_platform(self):
"""Test storefront API requests on localhost extract platform from Referer."""
mock_app = AsyncMock()
middleware = PlatformContextMiddleware(app=mock_app)
mock_platform = Mock()
mock_platform.id = 3
mock_platform.code = "loyalty"
mock_platform.name = "Loyalty Platform"
scope = {
"type": "http",
"path": "/api/v1/storefront/auth/login",
"headers": [
(b"host", b"localhost:8000"),
(b"referer", b"http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/login"),
],
}
receive = AsyncMock()
send = AsyncMock()
mock_db = MagicMock()
with patch(
"middleware.platform_context.get_db", return_value=iter([mock_db])
), patch.object(
PlatformContextManager,
"get_platform_from_context",
return_value=mock_platform,
):
await middleware(scope, receive, send)
# Platform should be detected from Referer
assert scope["state"]["platform"] is mock_platform
assert scope["state"]["platform_context"]["path_prefix"] == "loyalty"
# API path should NOT be rewritten to the Referer's path
assert scope["path"] == "/api/v1/storefront/auth/login"

View File

@@ -733,6 +733,115 @@ class TestStoreContextMiddleware:
assert request.state.clean_path == "/random/path"
call_next.assert_called_once_with(request)
# ========================================================================
# Storefront API Referer Tests
# ========================================================================
@pytest.mark.asyncio
async def test_middleware_storefront_api_uses_referer(self):
"""Test storefront API requests get store context from Referer header."""
middleware = StoreContextMiddleware(app=None)
request = Mock(spec=Request)
request.headers = {
"host": "localhost",
"referer": "http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/login",
}
request.url = Mock(path="/api/v1/storefront/auth/login")
request.state = Mock()
call_next = AsyncMock(return_value=Mock())
mock_store = Mock()
mock_store.id = 4
mock_store.name = "Fashion Hub"
mock_store.subdomain = "fashionhub"
referer_context = {
"subdomain": "FASHIONHUB",
"detection_method": "path",
"path_prefix": "/storefront/FASHIONHUB",
"full_prefix": "/storefront/",
"host": "localhost",
"referer": "http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/login",
}
mock_db = MagicMock()
with (
patch.object(
StoreContextManager,
"is_api_request",
return_value=True,
),
patch.object(
StoreContextManager,
"extract_store_from_referer",
return_value=referer_context,
),
patch.object(
StoreContextManager,
"get_store_from_context",
return_value=mock_store,
),
patch("middleware.store_context.get_db", return_value=iter([mock_db])),
):
await middleware.dispatch(request, call_next)
assert request.state.store is mock_store
assert request.state.store_context is referer_context
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_middleware_storefront_api_no_referer(self):
"""Test storefront API request without Referer header sets store to None."""
middleware = StoreContextMiddleware(app=None)
request = Mock(spec=Request)
request.headers = {"host": "localhost"}
request.url = Mock(path="/api/v1/storefront/auth/login")
request.state = Mock()
call_next = AsyncMock(return_value=Mock())
with (
patch.object(
StoreContextManager,
"is_api_request",
return_value=True,
),
patch.object(
StoreContextManager,
"extract_store_from_referer",
return_value=None,
),
):
await middleware.dispatch(request, call_next)
assert request.state.store is None
assert request.state.store_context is None
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_middleware_non_storefront_api_still_skips(self):
"""Test non-storefront API requests still skip store detection."""
middleware = StoreContextMiddleware(app=None)
request = Mock(spec=Request)
request.headers = {"host": "localhost"}
request.url = Mock(path="/api/v1/admin/stores")
request.state = Mock()
call_next = AsyncMock(return_value=Mock())
with patch.object(
StoreContextManager, "is_api_request", return_value=True
):
await middleware.dispatch(request, call_next)
assert request.state.store is None
call_next.assert_called_once_with(request)
# ========================================================================
# System Path Skipping Tests
# ========================================================================

View File

@@ -0,0 +1,125 @@
# tests/unit/test_exception_handler.py
"""
Unit tests for exception handler login redirect.
Tests the _redirect_to_login function to ensure correct URL generation
for storefront contexts, preventing double 'storefront/' in redirects.
"""
from unittest.mock import Mock
import pytest
from fastapi import Request
from app.exceptions.handler import _redirect_to_login
from app.modules.enums import FrontendType
def _make_request(
path="/storefront/FASHIONHUB/account/dashboard",
frontend_type=FrontendType.STOREFRONT,
store=None,
store_context=None,
platform=None,
platform_original_path=None,
):
request = Mock(spec=Request)
request.url = Mock(path=path)
request.method = "GET"
request.headers = {"accept": "text/html"}
request.state = Mock()
request.state.frontend_type = frontend_type
request.state.store = store
request.state.store_context = store_context
request.state.platform = platform
request.state.platform_original_path = platform_original_path
return request
def _make_store(store_code="FASHIONHUB", subdomain="fashionhub"):
store = Mock()
store.store_code = store_code
store.subdomain = subdomain
return store
def _make_platform(code="loyalty"):
platform = Mock()
platform.code = code
return platform
@pytest.mark.unit
class TestRedirectToLogin:
"""Tests for _redirect_to_login storefront URL generation."""
def test_storefront_redirect_dev_mode_no_double_storefront(self):
"""Dev mode redirect should not have double 'storefront/' in URL."""
store = _make_store(store_code="FASHIONHUB")
platform = _make_platform(code="loyalty")
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
request = _make_request(
store=store,
store_context=store_context,
platform=platform,
platform_original_path="/platforms/loyalty/storefront/FASHIONHUB/account/dashboard",
)
response = _redirect_to_login(request)
assert response.status_code == 302
location = response.headers["location"]
assert location == "/platforms/loyalty/storefront/FASHIONHUB/account/login"
assert "/storefront/storefront/" not in location
def test_storefront_redirect_prod_mode(self):
"""Prod mode redirect should use /account/login."""
store = _make_store()
store_context = {"detection_method": "subdomain", "subdomain": "fashionhub"}
request = _make_request(
store=store,
store_context=store_context,
)
response = _redirect_to_login(request)
assert response.status_code == 302
location = response.headers["location"]
assert location == "/account/login"
def test_storefront_redirect_uses_store_code_not_subdomain(self):
"""Redirect URL should use store_code (uppercase), not subdomain (lowercase)."""
store = _make_store(store_code="FASHIONHUB", subdomain="fashionhub")
platform = _make_platform(code="loyalty")
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
request = _make_request(
store=store,
store_context=store_context,
platform=platform,
platform_original_path="/platforms/loyalty/storefront/FASHIONHUB/dashboard",
)
response = _redirect_to_login(request)
location = response.headers["location"]
assert "FASHIONHUB" in location
assert "fashionhub" not in location
def test_admin_redirect(self):
"""Admin requests redirect to /admin/login."""
request = _make_request(frontend_type=FrontendType.ADMIN)
response = _redirect_to_login(request)
assert response.headers["location"] == "/admin/login"
def test_merchant_redirect(self):
"""Merchant requests redirect to /merchants/login."""
request = _make_request(frontend_type=FrontendType.MERCHANT)
response = _redirect_to_login(request)
assert response.headers["location"] == "/merchants/login"

View File

@@ -0,0 +1,146 @@
# tests/unit/utils/test_page_context.py
"""
Unit tests for page context utilities.
Tests cover:
- base_url calculation for storefront pages
- Dev mode (path-based) with platform prefix
- Prod mode (subdomain/custom domain)
- Ensures no double 'storefront/' in URLs
"""
from unittest.mock import Mock
import pytest
from fastapi import Request
from app.modules.core.utils.page_context import get_storefront_context
def _make_storefront_request(
path="/storefront/TESTSHOP/products",
store=None,
store_context=None,
platform=None,
platform_original_path=None,
theme=None,
clean_path="/products",
language="en",
):
"""Create a mock request with storefront state."""
request = Mock(spec=Request)
request.url = Mock(path=path)
request.method = "GET"
request.headers = {"host": "localhost:8000"}
request.state = Mock()
request.state.store = store
request.state.store_context = store_context
request.state.clean_path = clean_path
request.state.platform = platform
request.state.platform_original_path = platform_original_path
request.state.theme = theme
request.state.language = language
request.state.subscription = None
request.state.subscription_tier = None
request.state.frontend_type = None
return request
def _make_store(store_code="TESTSHOP", subdomain="testshop"):
store = Mock()
store.store_code = store_code
store.subdomain = subdomain
store.id = 1
store.name = "Test Shop"
store.merchant_id = 1
return store
def _make_platform(code="loyalty", platform_id=3):
platform = Mock()
platform.code = code
platform.id = platform_id
return platform
@pytest.mark.unit
class TestBaseUrlCalculation:
"""Tests for base_url in storefront context -- prevents double 'storefront/' regression."""
def test_base_url_dev_mode_with_platform_prefix(self):
"""Dev mode: base_url includes /platforms/{code}/storefront/{store_code}/."""
store = _make_store(store_code="FASHIONHUB")
platform = _make_platform(code="loyalty")
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
request = _make_storefront_request(
store=store,
store_context=store_context,
platform=platform,
platform_original_path="/platforms/loyalty/storefront/FASHIONHUB/products",
)
# Mock the module-level context building to avoid DB calls
# We only need to test the base_url logic, so patch what's needed
context = get_storefront_context(request)
assert context["base_url"] == "/platforms/loyalty/storefront/FASHIONHUB/"
# Verify no double storefront when building links
assert "/storefront/storefront/" not in context["base_url"]
def test_base_url_dev_mode_without_platform_prefix(self):
"""Dev mode without /platforms/ prefix: base_url = /storefront/{store_code}/."""
store = _make_store(store_code="TESTSHOP")
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
request = _make_storefront_request(
store=store,
store_context=store_context,
platform=None,
platform_original_path=None,
)
context = get_storefront_context(request)
assert context["base_url"] == "/storefront/TESTSHOP/"
assert "/storefront/storefront/" not in context["base_url"]
def test_base_url_prod_mode_subdomain(self):
"""Prod mode with subdomain: base_url = /."""
store = _make_store()
store_context = {"detection_method": "subdomain", "subdomain": "testshop"}
request = _make_storefront_request(
store=store,
store_context=store_context,
)
context = get_storefront_context(request)
assert context["base_url"] == "/"
def test_base_url_no_store_context(self):
"""No store context: base_url = /."""
request = _make_storefront_request(store=None, store_context=None)
context = get_storefront_context(request)
assert context["base_url"] == "/"
def test_base_url_uses_store_code_not_subdomain(self):
"""base_url should use store_code (uppercase) not subdomain (lowercase)."""
store = _make_store(store_code="FASHIONHUB", subdomain="fashionhub")
platform = _make_platform(code="loyalty")
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
request = _make_storefront_request(
store=store,
store_context=store_context,
platform=platform,
platform_original_path="/platforms/loyalty/storefront/FASHIONHUB/products",
)
context = get_storefront_context(request)
assert "FASHIONHUB" in context["base_url"]
assert "fashionhub" not in context["base_url"]