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

@@ -236,13 +236,25 @@ function adminLogin() {
// Super admin or single platform - proceed to dashboard
this.success = 'Login successful! Redirecting...';
// Check for last visited page (saved before logout)
// Decide where to land after login. Precedence:
// 1. ?next=<path> — set by apiClient on 401 mid-session;
// lands the user back on the page they were on.
// 2. admin_last_visited_page localStorage — fallback for
// organic logins where no `?next=` is supplied.
// 3. /admin/dashboard — last-resort default.
// Path safety check on (1) and (2): must start with /admin/
// and not be a login / select-platform URL.
const nextParam = new URLSearchParams(window.location.search).get('next');
const lastPage = localStorage.getItem('admin_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/admin/') && !lastPage.includes('/login') && !lastPage.includes('/select-platform'))
? lastPage
: '/admin/dashboard';
const isSafeAdminUrl = (u) =>
u && u.startsWith('/admin/') && !u.includes('/login') && !u.includes('/select-platform');
const redirectTo =
isSafeAdminUrl(nextParam) ? nextParam :
isSafeAdminUrl(lastPage) ? lastPage :
'/admin/dashboard';
loginLog.info('=== EXECUTING REDIRECT ===');
loginLog.debug('next param:', nextParam);
loginLog.debug('Last visited page:', lastPage);
loginLog.debug('Target URL:', redirectTo);

View File

@@ -131,13 +131,21 @@ function merchantLogin() {
// Show success message
this.success = 'Login successful! Redirecting...';
// Check for last visited page
// Decide where to land after login. Precedence:
// 1. ?next=<path> — set by apiClient on 401 mid-session.
// 2. merchant_last_visited_page localStorage fallback.
// 3. /merchants/dashboard default.
const nextParam = new URLSearchParams(window.location.search).get('next');
const lastPage = localStorage.getItem('merchant_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/merchants/') && !lastPage.includes('/login'))
? lastPage
: '/merchants/dashboard';
const isSafeMerchantUrl = (u) =>
u && u.startsWith('/merchants/') && !u.includes('/login');
const redirectTo =
isSafeMerchantUrl(nextParam) ? nextParam :
isSafeMerchantUrl(lastPage) ? lastPage :
'/merchants/dashboard';
loginLog.info('Redirecting to:', redirectTo);
loginLog.debug('next param:', nextParam, '| lastPage:', lastPage);
window.location.href = redirectTo;
} catch (error) {

View File

@@ -157,18 +157,33 @@ function storeLogin() {
? `/platforms/${platformCode}/store/${this.storeCode}`
: `/store/${this.storeCode}`;
// Check for last visited page (saved before logout)
// Decide where to land after login. Precedence:
// 1. ?next=<path> — set by apiClient on 401 mid-session;
// must be a /store/{code}/... URL that doesn't loop back
// to login or onboarding.
// 2. store_last_visited_page localStorage — fallback for
// organic logins (preserves the store-relative sub-path
// even if the basePath prefix changed, e.g. domain swap).
// 3. {basePath}/dashboard — last-resort default.
const nextParam = new URLSearchParams(window.location.search).get('next');
const lastPage = localStorage.getItem('store_last_visited_page');
let redirectTo = `${basePath}/dashboard`;
const isSafeStoreUrl = (u) =>
u && u.includes(`/store/${this.storeCode}/`) &&
!u.includes('/login') && !u.includes('/onboarding');
if (lastPage && !lastPage.includes('/login') && !lastPage.includes('/onboarding')) {
// Extract the store-relative path (strip any existing prefix)
let redirectTo = `${basePath}/dashboard`;
if (isSafeStoreUrl(nextParam)) {
redirectTo = nextParam;
} else if (lastPage && !lastPage.includes('/login') && !lastPage.includes('/onboarding')) {
// Preserve only the store-relative sub-path; basePath
// may have changed (subdomain ↔ /platforms/... etc.)
const storePathMatch = lastPage.match(/\/store\/[^/]+(\/.*)/);
if (storePathMatch) {
redirectTo = `${basePath}${storePathMatch[1]}`;
}
}
storeLoginLog.info('next param:', nextParam);
storeLoginLog.info('Last visited page:', lastPage);
storeLoginLog.info('Redirecting to:', redirectTo);