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 @@ + + + + + + Přihlášení vypršelo + + + Tvoje přihlášení vypršelo. Přihlaš se prosím znovu, aby se neztratila rozpracovaná data. + + + + + + + + + + + + + + + + + + + + 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 => {
Tvoje přihlášení vypršelo. Přihlaš se prosím znovu, aby se neztratila rozpracovaná data.