diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d60ff0f..7ddfdef 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1118,7 +1118,17 @@ activeTab.value = "tokens"; } +// Sessions end mid-flight too — a global logout webhook, a TTL, a revoked +// session. Any 401 that api.js sees drops the user onto the sign-in screen +// immediately, without a page reload, so nothing looks like it still works. +const onUnauthenticated = () => { + authState.value = "denied"; + // the app shell is unmounting; route-sync handlers have nothing to sync + window.removeEventListener("popstate", onPopState); +}; + onMounted(async () => { + window.addEventListener("gncreds:unauthenticated", onUnauthenticated); try { me.value = await api.me(); // the backend decides the language: settings override -> auth account. @@ -1140,6 +1150,7 @@ onBeforeUnmount(() => { window.removeEventListener("popstate", onPopState); + window.removeEventListener("gncreds:unauthenticated", onUnauthenticated); }); watch(activeTab, (tab) => { diff --git a/frontend/src/api.js b/frontend/src/api.js index 6d2c383..de50ccc 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -23,13 +23,20 @@ if (!response.ok) { const error = new Error(payload?.error?.message || "Request failed"); error.status = response.status; + // A session can die mid-flight (global logout webhook, TTL) — any + // 401 tells the SPA to drop onto the sign-in screen without a reload. + if (response.status === 401) { + window.dispatchEvent(new Event("gncreds:unauthenticated")); + } throw error; } return payload; } catch (err) { lastError = err; - const isNetworkError = !err.message?.includes("Request failed"); - if (!isNetworkError || attempt >= retries - 1) { + // Retry only what a repeat could fix: network failures (no status) + // and 5xx. A 4xx answer is final — notably a 401 must not be re-sent. + const isRetryable = err.status ? err.status >= 500 : true; + if (!isRetryable || attempt >= retries - 1) { throw err; } await sleep(300 * Math.pow(2, attempt));