From 3f8ef6b985721b39d659979f47d3a92d7cbe3c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 14 Sep 2026 13:23:31 +0200 Subject: [PATCH] Fix access token refresh for mutations, add login overlay fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutations never triggered the token-refresh path — only query() set the _refreshUnauthorizedUser flag apollo.js's errorLink checks, so a 401 on a mutation silently dropped the change instead of retrying. handleRefreshToken also had a latent bug (.then(value => value.flatMap(...)) on a string access token), which would have thrown even for queries. Changes: - stores/helpers.js: mutate() now sets _refreshUnauthorizedUser like query() does. - boot/apollo.js: fix the flatMap bug; when the token refresh call itself fails, show a persistent, non-dismissible login overlay instead of navigating to /login (which would discard in-progress form state), then retry the exact same query/mutation after successful login. Dedup concurrent failures behind one shared overlay/promise. Add a loop guard so a retried operation that 401s again fails cleanly instead of retrying forever. mutateImpl no longer swallows errors into a fake success. - stores/Auth.js: dedupe concurrent refreshAccessToken() calls via an in-flight promise. - components/auth/LoginOverlayDialog.vue: new persistent login dialog used by the overlay fallback above. Co-Authored-By: Claude Sonnet 5 --- src/boot/apollo.js | 79 +++++++++++++++++----- src/components/auth/LoginOverlayDialog.vue | 57 ++++++++++++++++ src/stores/Auth.js | 30 +++++--- src/stores/helpers.js | 4 +- 4 files changed, 142 insertions(+), 28 deletions(-) create mode 100644 src/components/auth/LoginOverlayDialog.vue diff --git a/src/boot/apollo.js b/src/boot/apollo.js index 892119f..5f4bbb1 100644 --- a/src/boot/apollo.js +++ b/src/boot/apollo.js @@ -2,32 +2,74 @@ import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core' import {ErrorLink} from "@apollo/client/link/error"; import {defineStore} from "pinia"; import {useAppStore} from "stores/App"; -import {Notify} from "quasar"; +import {Dialog, Notify} from "quasar"; import {errorObject, warningObject} from "src/composables/dialog"; import {useAuthStore} from "stores/Auth"; import UploadHttpLink from "apollo-upload-client/UploadHttpLink.mjs"; +import LoginOverlayDialog from "components/auth/LoginOverlayDialog.vue"; + +// Dedup across concurrently failing operations: if a query and a mutation +// both 401 around the same time, they must share one login overlay instead +// of each opening its own dialog. Module-level state is fine here (unlike +// the SSR-sensitive apolloWrapper below) because this path only ever runs +// client-side, one browser tab per module instance. +let pendingLoginPromise = null; + +const showLoginOverlay = (store) => { + if (pendingLoginPromise) return pendingLoginPromise; + + Notify.create(warningObject("Tvoje přihlášení vypršelo. Přihlaš se prosím znovu.")); + + pendingLoginPromise = new Promise((resolve) => { + Dialog.create({ + component: LoginOverlayDialog, + persistent: true, + }).onOk(() => resolve(useAuthStore(store).getAccessToken())); + }).finally(() => { + pendingLoginPromise = null; + }); + + return pendingLoginPromise; +}; + +const retryWithToken = (operation, forward) => (accessToken) => { + const oldHeaders = operation.getContext().headers; + operation.setContext({ + headers: { + ...oldHeaders, + authorization: `Bearer ${accessToken}`, + }, + }); + + // retry the request, returning the new observable + return forward(operation); +}; const handleRefreshToken = (store, router, forward, operation) => { if (!operation.query?._refreshUnauthorizedUser) return; - console.log("refreshing token, ", operation.query); - return useAuthStore(store).refreshAccessToken() - .catch((error) => { - Notify.create(warningObject("Tvoje přihlášení vypršelo. Přihlaš se prosím znovu.")); - router.push({name: "login"}) - }) - .then(value => value.flatMap((accessToken) => { - const oldHeaders = operation.getContext().headers; - operation.setContext({ - headers: { - ...oldHeaders, - authorization: `Bearer ${accessToken}`, - }, - }); + // Loop guard: if the operation retried after a refresh/login still comes + // back 401, don't try forever - surface a hard error instead. + if (operation.getContext()._tokenRefreshAttempted) { + Notify.create(errorObject("Přihlášení se nezdařilo, zkus to prosím znovu.")); + return Promise.reject(new Error("Token refresh retry failed")); + } + operation.setContext({_tokenRefreshAttempted: true}); - // retry the request, returning the new observable - return forward(operation); - })) + const retry = retryWithToken(operation, forward); + + // The overlay only makes sense client-side (no DOM during SSR); keep the + // previous redirect-to-login behavior there. + const onRefreshFailed = process.env.SERVER + ? () => { + router.push({name: "login"}); + return Promise.reject(new Error("Token refresh failed (SSR)")); + } + : () => showLoginOverlay(store).then(retry); + + return useAuthStore(store).refreshAccessToken() + .then(retry) + .catch(onRefreshFailed); } // SSR: boot() runs again for every request. The client/query/mutate below @@ -124,6 +166,7 @@ export const useApolloStore = defineStore('Apollo', () => { .then((response) => response) .catch(err => { console.error("Error during mutation: ", err); + return Promise.reject(err); }) }; } diff --git a/src/components/auth/LoginOverlayDialog.vue b/src/components/auth/LoginOverlayDialog.vue new file mode 100644 index 0000000..3fa58ee --- /dev/null +++ b/src/components/auth/LoginOverlayDialog.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/stores/Auth.js b/src/stores/Auth.js index 8df0b8d..4f1cd75 100644 --- a/src/stores/Auth.js +++ b/src/stores/Auth.js @@ -42,14 +42,28 @@ export const useAuthStore = defineStore('Auth', () => { const hasAccessToken = () => !!getAccessToken(); - const refreshAccessToken = async () => api.post("/refresh") - .then(({data}) => { - setTokens(data); - return data.access_token; - }).catch((e) => { - console.log("cannot refresh token", e); - return Promise.reject(e); - }) + // In-flight guard: if several requests 401 at the same time, they must all + // await the same refresh call instead of each firing its own POST /refresh + // (thundering herd, and a real risk if the backend rotates the refresh token + // on use). + let refreshPromise = null; + + const refreshAccessToken = async () => { + if (refreshPromise) return refreshPromise; + + refreshPromise = api.post("/refresh") + .then(({data}) => { + setTokens(data); + return data.access_token; + }).catch((e) => { + console.log("cannot refresh token", e); + return Promise.reject(e); + }).finally(() => { + refreshPromise = null; + }); + + return refreshPromise; + }; return {login, logout, getAccessToken, hasAccessToken, refreshAccessToken} }); diff --git a/src/stores/helpers.js b/src/stores/helpers.js index 9636e87..c3e3c63 100644 --- a/src/stores/helpers.js +++ b/src/stores/helpers.js @@ -57,11 +57,11 @@ export const query = ({isQueryRunning, query, variables, refreshUnauthorized}) = }); } -export const mutate = ({isMutationRunning, mutation, variables}) => { +export const mutate = ({isMutationRunning, mutation, variables, refreshUnauthorized}) => { isMutationRunning.value = true; let mutationName = getOperationName(mutation); - // TODO: pridat refresh tokenu! + mutation._refreshUnauthorizedUser = typeof refreshUnauthorized === 'undefined' ? true : refreshUnauthorized; return apolloWrapper.mutate(mutation, variables) .then(result => {