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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
160bc1b031
commit
3f8ef6b985
+61
-18
@@ -2,32 +2,74 @@ import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core'
|
|||||||
import {ErrorLink} from "@apollo/client/link/error";
|
import {ErrorLink} from "@apollo/client/link/error";
|
||||||
import {defineStore} from "pinia";
|
import {defineStore} from "pinia";
|
||||||
import {useAppStore} from "stores/App";
|
import {useAppStore} from "stores/App";
|
||||||
import {Notify} from "quasar";
|
import {Dialog, Notify} from "quasar";
|
||||||
import {errorObject, warningObject} from "src/composables/dialog";
|
import {errorObject, warningObject} from "src/composables/dialog";
|
||||||
import {useAuthStore} from "stores/Auth";
|
import {useAuthStore} from "stores/Auth";
|
||||||
import UploadHttpLink from "apollo-upload-client/UploadHttpLink.mjs";
|
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) => {
|
const handleRefreshToken = (store, router, forward, operation) => {
|
||||||
if (!operation.query?._refreshUnauthorizedUser) return;
|
if (!operation.query?._refreshUnauthorizedUser) return;
|
||||||
|
|
||||||
console.log("refreshing token, ", operation.query);
|
// Loop guard: if the operation retried after a refresh/login still comes
|
||||||
return useAuthStore(store).refreshAccessToken()
|
// back 401, don't try forever - surface a hard error instead.
|
||||||
.catch((error) => {
|
if (operation.getContext()._tokenRefreshAttempted) {
|
||||||
Notify.create(warningObject("Tvoje přihlášení vypršelo. Přihlaš se prosím znovu."));
|
Notify.create(errorObject("Přihlášení se nezdařilo, zkus to prosím znovu."));
|
||||||
router.push({name: "login"})
|
return Promise.reject(new Error("Token refresh retry failed"));
|
||||||
})
|
}
|
||||||
.then(value => value.flatMap((accessToken) => {
|
operation.setContext({_tokenRefreshAttempted: true});
|
||||||
const oldHeaders = operation.getContext().headers;
|
|
||||||
operation.setContext({
|
|
||||||
headers: {
|
|
||||||
...oldHeaders,
|
|
||||||
authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// retry the request, returning the new observable
|
const retry = retryWithToken(operation, forward);
|
||||||
return forward(operation);
|
|
||||||
}))
|
// 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
|
// SSR: boot() runs again for every request. The client/query/mutate below
|
||||||
@@ -124,6 +166,7 @@ export const useApolloStore = defineStore('Apollo', () => {
|
|||||||
.then((response) => response)
|
.then((response) => response)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error during mutation: ", 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
@@ -42,14 +42,28 @@ export const useAuthStore = defineStore('Auth', () => {
|
|||||||
|
|
||||||
const hasAccessToken = () => !!getAccessToken();
|
const hasAccessToken = () => !!getAccessToken();
|
||||||
|
|
||||||
const refreshAccessToken = async () => api.post("/refresh")
|
// In-flight guard: if several requests 401 at the same time, they must all
|
||||||
.then(({data}) => {
|
// await the same refresh call instead of each firing its own POST /refresh
|
||||||
setTokens(data);
|
// (thundering herd, and a real risk if the backend rotates the refresh token
|
||||||
return data.access_token;
|
// on use).
|
||||||
}).catch((e) => {
|
let refreshPromise = null;
|
||||||
console.log("cannot refresh token", e);
|
|
||||||
return Promise.reject(e);
|
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}
|
return {login, logout, getAccessToken, hasAccessToken, refreshAccessToken}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
isMutationRunning.value = true;
|
||||||
let mutationName = getOperationName(mutation);
|
let mutationName = getOperationName(mutation);
|
||||||
|
|
||||||
// TODO: pridat refresh tokenu!
|
mutation._refreshUnauthorizedUser = typeof refreshUnauthorized === 'undefined' ? true : refreshUnauthorized;
|
||||||
|
|
||||||
return apolloWrapper.mutate(mutation, variables)
|
return apolloWrapper.mutate(mutation, variables)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
|
|||||||
Reference in New Issue
Block a user