Vue Router - redirect to the same route does not triggers afterEach() guard - vue.js

I'm usign vue-router for a Single Page Application and I've implemented several guards to use into the router itself. I've noticed that if a guard redirects me to the same page where I actually am, the afterEach() function does not tiggers.
Let me give You an example. Imagine you have the following flow:
I'm in the Login page; I use my credentials and I'm redirected to the Home page. Here I click the 'back' button to go back to the Login page, but a guard, that checks if I'm already logged in, redirects me back to the Home page.
In this scenario, the afterEach() function does not triggers.
How can I solve this problem?

This solution won't work for all cases, but it could work for the example you mentioned if you implement an additional manual navigation guard in your router's beforeEach hook.
Assume you have the following code where a user tries to navigate from /home to /login and ends up back on /home without the afterEach hook firing:
router.beforeEach((to, from, next) => {
if (user.isAuthenticated() && to.path === '/login') {
return next('/home')
}
return next()
})
router.afterEach(() => {
// some logic
})
Refactor this to:
const afterEach = function() {
// some logic
}
router.beforeEach((to, from, next) => {
if (user.isAuthenticated() && to.path === '/login') {
if (from.path === '/home') {
afterEach()
}
return next('/home')
}
return next()
})
router.afterEach(afterEach)

Related

Wait for state update in vuejs router´s beforeEnter

I want to restrict access to certain pages in my vue router. Instead of having the auth logic in each component, I would prefer, for instance, to just have a 'hasUserAccess' check in my child-routes where it´s needed
{
path: 'admin',
name: 'admin',
beforeEnter: hasUserAccess,
component: () => import(/* webpackChunkName: "admin" */ '#/_ui/admin/Admin.vue')
},
...
function hasUserAccess(to, from, next) {
if (myState.user.isAdmin) {
next();
} else {
next({ path: '/noaccess' });
}
}
This works as intended when navigating from another page to the 'admin' page. This does not work when i manually type the /admin url (or pressing f5 while on the admin page) because the user object hasn´t been fetched from the server yet (some other logic is taking care of fetching the user).
The 'beforeEnter' is async, but as far as I know it ain´t possible to 'watch' the user object, or await it, from the router since the router is not a typical vue component.
So how is this common problem normally solved?
Just apply the beforeEach to the router itself. On the router file, you could do this:
router.beforeEach((to, from, next) => {
//in case you need to add more public pages like blog, about, etc
const publicPages = ["/login"];
//check if the "to" path is a public page or not
const authRequired = !publicPages.includes(to.path);
//If the page is auth protected and hasUserAccess is false
if (authRequired && !hasUserAccess) {
//return the user to the login to force the user to login
return next("/login");
}
//The conditional is false, then send the user to the right place
return next();
});
Try to modify this at your convenience, but this is more or less what I do in a situation like yours.

Prevent route change on refresh with Vue navigation guards

I have navigation guards working perfectly based on a userTp value, however whenever the user reloads a page it redirects them to name: 'login'.
I would like to prevent a route change on refresh. Is there a simple way to do this within navigation guards?
router.beforeEach(async (to, from, next) => {
if (to.meta.requireAuth) {
if (!store.state.auth.user) {
await store.dispatch(AUTH_REFRESH)
}
if (to.meta.userTp === store.state.auth.user.userTp) {
next()
window.scrollTo(0, 0)
} else {
next({ name: 'login' })
window.scrollTo(0, 0)
}
} else {
next()
window.scrollTo(0, 0)
}
})
EDIT:
I found a semi-workaround for this, but it's not perfect. It allows users to access pages if they know the correct page url path, but since my setup has a server-side userTp check before sending data it just shows the page template but without any data in it.
My temp solution:
(Added as the first if statement)
router.beforeEach(async (to, from, next) => {
if (to = from) {
if (!store.state.auth.user) {
await store.dispatch(AUTH_REFRESH)
to()
}
}

Vue router allways redirecting to error page

i am trying to setup a redirect when the user is not logged in. But when i do it like in my example the URL changes but i get This page could not be found from nuxt. The code is inside an login.js inside the plugins folder. Then i included this in the nuxt config like this.
plugins: [
'~/plugins/login.js'
],
And here is the actual code for handling redirecting
export default ({ app, store }) => {
app.router.beforeEach((to, from, next) => {
const loggedIn = store.state.account.loggedInAccount
if (!loggedIn) {
if (to.path !== '/redirect') {
next({ path: '/redirect' })
} else {
next()
}
} else {
next()
}
})
}
It looks like the routes are not mounted yet.
You should try to use middleware. It is the conventional way to implement the beforeEach function as mentioned by the official docs. You can read about it from here. If have access to the route object, store object and redirect function inside the middleware, so use redirect to direct to the other routes after validation.

vue-router — Uncaught (in promise) Error: Redirected from "/login" to "/" via a navigation guard

Why is vue-router giving me this error? To be clear, the login flow works as intended but I want to a) get rid of the errro and b) understand why the error is happening.
Error:
Uncaught (in promise) Error: Redirected from "/login" to "/" via a navigation guard.
Login flow
start logged out, but enter a URL that requires auth (i.e. anything besides "/login")
get redirected to "/login" (as expected).
login
successfully get redirected to starting Url from step #1, except with the above error.
Login action:
doLogin({ commit }, loginData) {
commit("loginStart");
axiosClient
.post("/jwt-auth/v1/token", {
username: loginData.username,
password: loginData.password,
})
.then((response) => {
commit("loginStop", null);
commit("setUserData", response.data);
this.categories = airtableQuery.getTable("Categories");
commit("setCategories", this.categories);
this.locations = airtableQuery.getTable("Locations");
commit("setLocations", this.locations);
router.push("/"); // push to site root after authentication
})
.catch((error) => {
console.log(error.response.data.message);
commit("loginStop", error.response.data.message);
commit("delUserData");
});
},
Router:
const routes = [
{
path: "/login",
name: "Login",
component: Login,
meta: { requiresAuth: false },
},
{
path: "/",
name: "Home",
component: Home,
meta: { requiresAuth: true },
},
];
let entryUrl = null;
router.beforeEach((to, from, next) => {
let localStorageUserData = JSON.parse(localStorage.getItem("userData"));
let storeUserData = state.getters.getUserData;
let userData = localStorageUserData || storeUserData;
let isAuthenticated = userData.token !== "" && userData.token !== undefined;
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!isAuthenticated) {
if (to.name !== "Login" && to.name !== "Home") {
entryUrl = to.fullPath;
}
next({ name: "Login" });
} else if (entryUrl) {
let url = entryUrl;
entryUrl = null;
next(url);
} else {
next();
}
} else {
next();
}
});
I spent hours debugging this and got to the following results for the ugly Uncaught (in promise) Error: Redirected when going from ... Error.
Note that the error is not for the "redirect". It's for the initial caller of the first navigation. Keep reading...
It's by design. Why?
Read this comment.
TL;DR: Let's say you are on page A, and click on a button to take you to page B (kinda like method: goToB() { router.push('/B'); } on page A). But there is a Navigation Guard for page B, that sends you to page C.
This error is a way for letting that goToB() function know that the router hasn't been able to fulfill the desired task, and the user hasn't landed on /B.
It's nasty, but informative
The biggest confusion here is that the redirect (landing on Page C) is, both:
an "expected" outcome to you, the architect of the system. But, at the same time,
an "unexpected" event to the caller of goToB in page A (i.e. router.push), who expects the router to go to page B.
That's why when it's popped as Error, it's confusing and frustrating to "you", who looks at the system entirely and thinks nothing is wrong or erroneous!
Urrrgh... So, what should I do?
Solution 1: Use router-link if you can
I ran into a case that <router-link> was working fine, but router.push was complaining. (I think router-link internally suppresses such errors.)
Solution 2.1: Individual suppress errors on each router.push call
The router.push function is returning a Promise (as it can be considered, or will be, an asynchronous job). All you need to do is to suppress any Error it might throw via
router.push('/B').catch(() => {});
// Add this: ^^^^^^^^^^^^^^^^
Solution 2.2: Augment Router.prototype.push to always suppress errors
If you think you have multiple instances of this, you can augment the push function on the prototype of the Router via the snippet on the same comment to apply this to all the router.push calls on the entire app.
The good news is it's giving you granularity level to choose which error you want to suppress (e.g. only NavigationFailureTypes.redirected ones, for example. The enum is here)
If you are on TypeScript, be my guest on the conversion and typing https://gist.github.com/eyedean/ce6ab6a5108a1bd19ace64382144b5b0 :)
Other tips:
Upgrade your vue-router! Your case might be solved by the time you read this. (As they have a plan to do so, apparently.)
Make sure you are not forking or reaching to dead-end in your Navigation Guards, if you have multiple ones. Follow them one by one and track them step by step. Note that, double redirecting is fine (thanks to this answer), you just need to be double careful!
I also got a playground here: https://codepen.io/eyedean/pen/MWjmKjV You can start mimicking this to your need to figure out where your problem happens in the first place.
The error message is getting updated in the next version of vue-router. The error will read:
Redirected when going from "/login" to "/" via a navigation guard
Somewhere in your code, after being redirected to "/login", you are redirecting back to "/". And vue-router is complaining about. You'll want to make sure you only have one redirect per navigation action.
I had a similar error, but for an onboarding redirect in .beforeEach, which was resolved by replacing in the .beforeEach conditional logic:
next({ name: "Onboarding" });
with
router.push({ path: 'Onboarding' });
This error is meant to inform the caller of $router.push that the navigation didn't go to where it was initially intended. If you expect a redirection you can safely ignore the error with the following code.
import VueRouter from 'vue-router'
const { isNavigationFailure, NavigationFailureType } = VueRouter
...
this.$router.push('/')
.catch((e) => {
if (!isNavigationFailure(e, NavigationFailureType.redirected)) {
Promise.reject(e)
}
}
See https://github.com/vuejs/vue-router/issues/2932 for a discussion regarding this issue.
If your redirect is after a call to router.push('/someroute) then you can catch this error as router.push() is a promise and you can attach a catch to it as below
$router.push('/somesecureroute')
.catch(error => {
console.info(error.message)
})
I have the same error. This error created by router.push("/"); row - it trying to say you that pushing to home was interrupted by redirection in navigation guard.
But actually, it's not an error because it is an expected behaviour.
I made ignoring of such errors by the following way:
const router = new VueRouter({
mode: 'history',
routes: _routes,
});
/**
* Do not throw an exception if push is rejected by redirection from navigation guard
*/
const originalPush = router.push;
router.push = function push(location, onResolve, onReject) {
if (onResolve || onReject) {
return originalPush.call(this, location, onResolve, onReject);
}
return originalPush.call(this, location).catch((err) => {
let reg = new RegExp('^Redirected when going from "[a-z_.\\/]+" to "[a-z_.\\/]+" via a navigation guard.$');
if (reg.test(err.message)) {
// If pushing interrupted because of redirection from navigation guard - ignore it.
return Promise.resolve(false);
}
// Otherwise throw error
return Promise.reject(err);
});
};
I had the same issue, i thought it was config problem but it was not
You can try this code
async doLogin({ commit, dispatch }, loginData) {
commit("loginStart");
let response = await axiosClient
.post("/jwt-auth/v1/token", {
username: loginData.username,
password: loginData.password,
})
return dispacth('attempt', response)
}
async attempt({ commit }, response) {
try {
commit("loginStop", null);
commit("setUserData", response.data);
this.categories = airtableQuery.getTable("Categories");
commit("setCategories", this.categories);
this.locations = airtableQuery.getTable("Locations");
commit("setLocations", this.locations);
}
catch( (error) => {
console.log(error.response.data.message);
commit("loginStop", error.response.data.message);
commit("delUserData");
})
}
And in the component where doLogin action is called
this.doLogin(this.loginData)
.then( () => {
this.$router.replace('/')
.catch( error => {
console.log(error)
})
})
.catch( e => {
console.log(e)
})
It happened to me on application boot, during an authorization check I tried to push to '/auth' but that navigation was cancelled by another call to '/', which occurred just after mine.
So in the end I found that is possible to listen for readiness (https://router.vuejs.org/api/#isready) of vue-router using:
await router.isReady()
or
router.isReady().then(...)
I had the exact same issue triggered by two specific pages, for the two pages that were being redirected due to a login:
if (isAdmin){
router.push({name: 'page'}).catch(error=>{
console.info(error.message)})
else...
otherwise everyone else who is a regular user gets pushed to a different page using "router.push"
ONLY on the redirects that were throwing the original error/warning. Suppressing the warning as suggested in an earlier comment:
if (!to.matched.length) console.warn('no match');
next()
allowed for users to sign in and access pages without proper permissions.
Catching the errors appears to be the way to go, per the suggestion of: kissu

How to make Vue Router Guards wait for Vuex?

So all answers I've found publicly to this question weren't very helpful and while they "worked", they were incredibly hacky.
Basically I have a vuex variable, appLoading which is initially true but gets set to false once all async operations are complete. I also have another vuex variable called user which contains user information that gets dispatched from the async operation once it gets returned.
I then also have a router guard that checks;
router.beforeEach((to, from, next) => {
if (to.matched.some(route => route.meta.requiresAuth)) {
if (store.getters.getUser) {
return next();
}
return router.push({ name: 'index.signup' });
}
return next();
});
In my initial Vue instance I then display a loading state until appLoading = false;
Now this "works" but there is a problem which is really bugging me. If you load the page, you will get a "flicker" of the opposite page you are supposed to see.
So if you are logged in, on first load you will see a flicker of the signup page. If you aren't logged in, you will see a flicker of the logged in page.
This is pretty annoying and I narrowed the problem down to my auth guard.
Seems it's pushing the signup page to the router since user doesn't exist then instantly pushes to the logged in page since user gets committed.
How can I work around this in a way that isn't hacky since it's kinda annoying and it's sort of frustrating that Vue doesn't have even the slightest bit of official docs/examples for a problem as common as this, especially since such a large number of webapps use authentication.
Hopefully someone can provide some help. :)
The router beforeEach hook can be a promise and await for a Vuex action to finish. Something like:
router.beforeEach(async (to, from, next) => {
if (to.matched.some(route => route.meta.requiresAuth)) {
await store.dispatch('init');
if (store.getters.getUser) {
return next();
}
return router.push({ name: 'index.signup' });
}
return next();
});
The 'init' action should return a promise:
const actions = {
async init({commit}) {
const user = await service.getUser();
commit('setUser', user);
}
}
This approach has the problem that whenever we navigate to a given page it will trigger the 'init' action which will fetch the user from the server. We only want to fetch the user in case we don't have it, so we can update the store check if it has the user and fetch it acordingly:
const state = {
user: null
}
const actions = {
async init({commit, state}) {
if(!state.user) {
const user = await service.getUser();
commit('setUser', user);
}
}
}
As per discussion in comments:
Best approach for you case will be if you make your appLoading variable a promise. That's how you can do things or wait for things until your app data is resolved.
Considering appLoading a promise which you initialize with your api call promise, your router hook will be like:
router.beforeEach((to, from, next) => {
appLoading.then(() => {
if (to.matched.some(route => route.meta.requiresAuth)) {
if (store.getters.getUser) {
return next();
}
return router.push({ name: "index.signup" });
}
return next();
});
});
You might want to just keep it as an export from your init code instead of keeping it in Vuex. Vuex is meant for reactive data that is shared over components.