fix(api-client): generalize 401 redirect from /account/* to all 4 personas
All checks were successful
CI / ruff (push) Successful in 18s
CI / docs (push) Successful in 56s
CI / pytest (push) Successful in 2h48m6s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 37s
CI / deploy (push) Successful in 1m14s

Yesterday's redirectIfCustomerAreaUnauthorized was scoped to /account/*
only. Admin, store, and merchant pages still hit the same UX gap when
an AJAX call returned 401 on token expiry: apiClient cleared tokens
and threw, leaving the page in a broken state with whatever generic
error UI the caller had wired up — no redirect, no `?next=` round-trip,
identical bug to the customer flicker we fixed in `b04b36a2` /
`6564f138`.

Rename and dispatch by path:
  - /account/*     (not /account/login)         → /account/login?next=…
  - /admin/*       (not /admin/login)           → /admin/login?next=…
  - /merchants/*   (not /merchants/login)       → /merchants/login?next=…
  - /store/{code}/* (not /store/{code}/login)   → /store/{code}/login?next=…
  - anything else                               → return false (caller throws)

Store paths include the per-store code, so the helper does a small regex
to extract `{code}` from the current pathname and builds the persona's
login URL with the right prefix.

All three 401 handlers in apiClient (request, requestFormData, getBlob)
already wrap this with the `return new Promise(() => {})` pattern from
6564f138, so the caller's `.finally(() => loading = false)` doesn't fire
before navigation completes — kills the wrong-state UI flash on every
persona, not just customer.

Login pages updated to honour `?next=` precedence over the existing
`*_last_visited_page` localStorage fallback, with persona-specific
safety checks (must start with /admin/, /merchants/, /store/{code}/
respectively; must not be a login or onboarding URL). The store login
also normalises the basePath because the store-code path prefix can
flip between subdomain (/store/{code}/...) and dev/path-based
(/platforms/{platform}/store/{code}/...) modes.

Customer login already honoured `?next=` from bbb481aa; left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 13:02:59 +02:00
parent 947ca43c7b
commit 4423f0a5ed
4 changed files with 87 additions and 28 deletions

View File

@@ -155,7 +155,7 @@ class APIClient {
apiLog.debug('Error details:', data);
apiLog.info('Clearing authentication tokens');
this.clearTokens();
if (this.redirectIfCustomerAreaUnauthorized()) {
if (this.redirectIfUnauthorized()) {
// Page is navigating away to /account/login. Return a
// never-resolving promise so the caller's await never
// returns and any `.finally(() => loading = false)`
@@ -312,7 +312,7 @@ class APIClient {
if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed');
this.clearTokens();
if (this.redirectIfCustomerAreaUnauthorized()) {
if (this.redirectIfUnauthorized()) {
return new Promise(() => {});
}
throw new Error(data.message || data.detail || 'Unauthorized');
@@ -355,7 +355,7 @@ class APIClient {
if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed');
this.clearTokens();
if (this.redirectIfCustomerAreaUnauthorized()) {
if (this.redirectIfUnauthorized()) {
return new Promise(() => {});
}
throw new Error('Unauthorized');
@@ -451,24 +451,48 @@ class APIClient {
}
/**
* 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.
* If the user is on a protected page (customer / admin / store /
* merchant area) and gets a 401, send them to the persona's login
* page with a `?next=` 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).
* Dispatches by path:
* /account/* (not /account/login) → /account/login?next=...
* /admin/* (not /admin/login) → /admin/login?next=...
* /store/{code}/* (not /store/{code}/login) → /store/{code}/login?next=...
* /merchants/* (not /merchants/login) → /merchants/login?next=...
*
* Returns true if a redirect was scheduled (caller should suppress
* its own error UI since the page is about to navigate away).
* Returns true if a redirect was scheduled (caller should return a
* never-resolving promise so its `.finally(() => loading = false)`
* doesn't fire mid-redirect and flash a wrong UI state).
* Returns false for unknown paths or login pages — caller throws as
* usual.
*/
redirectIfCustomerAreaUnauthorized() {
redirectIfUnauthorized() {
const path = window.location.pathname;
const onCustomerArea = path.startsWith('/account/') && path !== '/account/login';
if (!onCustomerArea) return false;
let loginUrl = null;
if (path.startsWith('/account/') && path !== '/account/login') {
loginUrl = '/account/login';
} else if (path.startsWith('/admin/') && path !== '/admin/login') {
loginUrl = '/admin/login';
} else if (path.startsWith('/merchants/') && path !== '/merchants/login') {
loginUrl = '/merchants/login';
} else if (path.startsWith('/store/')) {
// Store paths include the store code: /store/{code}/<rest>.
// Login URL is /store/{code}/login. Skip if already on it.
const m = path.match(/^\/store\/([^/]+)\//);
if (m) {
const candidate = `/store/${m[1]}/login`;
if (path !== candidate) loginUrl = candidate;
}
}
if (!loginUrl) 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}`;
apiLog.info(`Redirecting to ${loginUrl} (session expired), next=${next}`);
window.location.href = `${loginUrl}?next=${next}`;
return true;
}