Author SHA1 Message Date
Michal KváčekandClaude Sonnet 5 3f8ef6b985 Fix access token refresh for mutations, add login overlay fallback
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 <noreply@anthropic.com>
2026-09-14 13:23:31 +02:00
4 changed files with 142 additions and 28 deletions
+61 -18
View File
@@ -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);
})
};
}
@@ -0,0 +1,57 @@
<template>
<q-dialog ref="dialogRef" persistent @hide="onDialogHide">
<q-card style="width: 95%; max-width: 400px;">
<q-form @submit.prevent="logUser">
<q-card-section class="bg-primary">
<h4 class="text-h6 q-my-md text-white">Přihlášení vypršelo</h4>
</q-card-section>
<q-card-section>
<p>Tvoje přihlášení vypršelo. Přihlaš se prosím znovu, aby se neztratila rozpracovaná data.</p>
<q-input v-model="formData.email" :rules="rules.email" type="email" label="E-mail">
<template v-slot:prepend>
<q-icon name="email"/>
</template>
</q-input>
<q-input v-model="formData.password" :rules="rules.password" type="password" label="Heslo">
<template v-slot:prepend>
<q-icon name="lock"/>
</template>
</q-input>
</q-card-section>
<q-card-actions>
<q-btn type="submit" unelevated size="lg" color="primary" class="full-width" :loading="loading" label="Přihlásit se"/>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</template>
<script setup>
import {reactive, ref} from "vue";
import {useDialogPluginComponent, useQuasar} from "quasar";
import {isRequired, isValidEmail, minLength} from "src/composables/form/rules";
import {errorObject} from "src/composables/dialog";
import {useAuthStore} from "stores/Auth";
const {dialogRef, onDialogHide, onDialogOK} = useDialogPluginComponent();
defineEmits([...useDialogPluginComponent.emits]);
const $q = useQuasar();
const loading = ref(false);
const formData = reactive({
email: "",
password: ""
});
const rules = {
email: [isRequired, isValidEmail],
password: [isRequired, minLength(4)]
};
const logUser = () => {
loading.value = true;
useAuthStore().login(formData)
.then(() => onDialogOK())
.catch(() => $q.notify(errorObject("Přihlášení se nezdařilo.")))
.finally(() => loading.value = false);
};
</script>
+22 -8
View File
@@ -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}
});
+2 -2
View File
@@ -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 => {