fix(storefront-auth): apiClient redirects to login on 401 from /account/*
Some checks failed
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled

When the customer's JWT (30-min TTL via JWT_EXPIRE_MINUTES) expires in
localStorage, subsequent API calls from a customer-area page returned
401 → callers showed an unrelated error UI (loyalty dashboard rendered
the "join now" CTA because card came back null on the catch path).

Three changes in static/shared/js/api-client.js:

1. Path detection in getToken() + clearTokens() now recognises
   /account/* and /api/v1/storefront/* as customer-area routes (the
   only existing checks were for /shop/* which was never used in this
   codebase). Also clears customer_user alongside customer_token.

2. New redirectIfCustomerAreaUnauthorized() helper: on a /account/*
   page, sends the browser to /account/login?next=<current path>
   (with a guard to skip the redirect when already on the login page,
   avoiding loops). Called from all three 401 paths (request,
   requestFormData, getBlob).

3. login.html now honours the ?next= query param (in addition to the
   legacy ?return=), so the redirect lands the user back where their
   session expired.

Other personas (admin/store/merchant) are unaffected — the helper is
a no-op outside /account/*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 21:28:34 +02:00
parent 3ce9468397
commit a0ae638821
2 changed files with 44 additions and 5 deletions

View File

@@ -295,9 +295,12 @@
this.showAlert('Login successful! Redirecting...', 'success'); this.showAlert('Login successful! Redirecting...', 'success');
// Redirect to account page or return URL // Redirect to account page or return URL.
// Accepts `?next=` (apiClient's 401-handler convention)
// or `?return=` (legacy) — `next` wins.
setTimeout(() => { setTimeout(() => {
const returnUrl = new URLSearchParams(window.location.search).get('return') || '{{ base_url }}account'; const params = new URLSearchParams(window.location.search);
const returnUrl = params.get('next') || params.get('return') || '{{ base_url }}account';
window.location.href = returnUrl; window.location.href = returnUrl;
}, 1000); }, 1000);

View File

@@ -56,7 +56,12 @@ class APIClient {
} else if (currentPath.startsWith('/admin/') || currentPath.startsWith('/api/v1/admin/')) { } else if (currentPath.startsWith('/admin/') || currentPath.startsWith('/api/v1/admin/')) {
token = adminToken; token = adminToken;
source = 'admin (path-based)'; source = 'admin (path-based)';
} else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) { } else if (
currentPath.startsWith('/account/') ||
currentPath.startsWith('/api/v1/storefront/') ||
currentPath.includes('/shop/') ||
currentPath.startsWith('/api/v1/shop/')
) {
token = customerToken; token = customerToken;
source = 'customer (path-based)'; source = 'customer (path-based)';
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) { } else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
@@ -145,12 +150,13 @@ class APIClient {
} }
} }
// Handle 401 Unauthorized - Just clear tokens, DON'T redirect // Handle 401 Unauthorized
if (response.status === 401) { if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed'); apiLog.warn('401 Unauthorized - Authentication failed');
apiLog.debug('Error details:', data); apiLog.debug('Error details:', data);
apiLog.info('Clearing authentication tokens'); apiLog.info('Clearing authentication tokens');
this.clearTokens(); this.clearTokens();
this.redirectIfCustomerAreaUnauthorized();
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);
@@ -299,6 +305,7 @@ class APIClient {
if (response.status === 401) { if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed'); apiLog.warn('401 Unauthorized - Authentication failed');
this.clearTokens(); this.clearTokens();
this.redirectIfCustomerAreaUnauthorized();
throw new Error(data.message || data.detail || 'Unauthorized'); throw new Error(data.message || data.detail || 'Unauthorized');
} }
@@ -339,6 +346,7 @@ class APIClient {
if (response.status === 401) { if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed'); apiLog.warn('401 Unauthorized - Authentication failed');
this.clearTokens(); this.clearTokens();
this.redirectIfCustomerAreaUnauthorized();
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
@@ -395,9 +403,15 @@ class APIClient {
localStorage.removeItem('store_user'); localStorage.removeItem('store_user');
localStorage.removeItem('currentUser'); localStorage.removeItem('currentUser');
localStorage.removeItem('storeCode'); localStorage.removeItem('storeCode');
} else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) { } else if (
currentPath.startsWith('/account/') ||
currentPath.startsWith('/api/v1/storefront/') ||
currentPath.includes('/shop/') ||
currentPath.startsWith('/api/v1/shop/')
) {
apiLog.info('Clearing customer tokens only'); apiLog.info('Clearing customer tokens only');
localStorage.removeItem('customer_token'); localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) { } else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
apiLog.info('Clearing merchant tokens only'); apiLog.info('Clearing merchant tokens only');
localStorage.removeItem('merchant_token'); localStorage.removeItem('merchant_token');
@@ -426,6 +440,28 @@ class APIClient {
apiLog.info('Context-specific tokens cleared'); apiLog.info('Context-specific tokens cleared');
} }
/**
* If the user is on a customer-area page (/account/*) and gets 401,
* send them to the login page with a return URL so they land back
* here after re-authenticating.
*
* No-op for admin/store/merchant areas — those callers handle 401
* their own way. Also no-op if already on the login page (avoids
* a redirect loop).
*
* Returns true if a redirect was scheduled (caller should suppress
* its own error UI since the page is about to navigate away).
*/
redirectIfCustomerAreaUnauthorized() {
const path = window.location.pathname;
const onCustomerArea = path.startsWith('/account/') && path !== '/account/login';
if (!onCustomerArea) return false;
const next = encodeURIComponent(path + window.location.search);
apiLog.info('Redirecting to /account/login (session expired), next=' + next);
window.location.href = `/account/login?next=${next}`;
return true;
}
/** /**
* Handle unauthorized access * Handle unauthorized access
* DEPRECATED - Now just clears tokens, doesn't redirect * DEPRECATED - Now just clears tokens, doesn't redirect