In the early stages of one of our newer projects, we implemented the login
screen and the required isAuthenticated
control by using local storage
directly. Since then we adopted vuetify and had to
redo the screen so why not take this opportunity to learn something new in the
process. Enter vuex.
The official page will explain what’s vuex better than I could ever be able to do. But as it’s also noted there “no one can be told what vuex is, you have to see it for yourself”. So just dive into it and stay there a while, you’ll get used to it soon enough.
Vue cli can configure vuex and router btw so if you haven’t done that before go do it now.
If you’re starting a new project like:
vue create my-project
You can select them manually in:
Vue CLI v4.5.9
? Please pick a preset:
Default ([Vue 2] babel, eslint)
Default (Vue 3 Preview) ([Vue 3] babel, eslint)
❯ Manually select features
and then
Vue CLI v4.5.9
? Please pick a preset: Manually select features
? Check the features needed for your project:
◉ Choose Vue version
◯ Babel
◯ TypeScript
◯ Progressive Web App (PWA) Support
◉ Router
◉ Vuex
◯ CSS Pre-processors
◯ Linter / Formatter
◯ Unit Testing
◯ E2E Testing
Or you can add them to your project like
vue add vuex
vue add router
Now let’s make a login page in views/pages/Login.vue
<template>
<v-app id="inspire">
<v-main class="grey lighten-3">
<v-form>
<v-card>
<v-card-text>
<v-text-field label="Username"/>
<v-text-field label="Password" type="password" />
</v-card-text>
<v-card-actions>
<v-btn type="submit">
Login
</v-btn>
</v-card-actions>
</v-card>
</v-form>
</v-main>
</v-app>
</template>
Of course there will be some areas that require login and some areas that don’t, at least the login page. Go ahead and configure them in your router.
const routes = [
{
path: "/",
name: "MainContainer",
redirect: "/home",
component: MainContainer,
children: [
{
path: "/home",
name: "Home",
component: Home,
meta: { requiresAuth: true },
},
],
},
{
path: "/pages",
redirect: "/pages/login",
name: "Pages",
children: [
{
path: "login",
name: "Login",
component: Login,
},
],
},
];
Whether the user is currently logged in or not and a token or session id forms a state which we define as a vuex store.
const getDefaultState = () => {
return {
token: null,
user: {},
};
};
const store = new Vuex.Store({
state: getDefaultState,
getters: {},
mutations: {},
actions: {},
modules: {},
plugins: {},
});
We’ll also define some methods to access and modify those values with functions called getters and mutations respectively.
getters: {
isAuthenticated: (state) => !!state.token,
user: (state) => state.user,
token: (state) => state.token,
},
mutations: {
SET_TOKEN(state, token) {
state.token = token;
},
LOGIN_USER(state, user) {
state.user = { ...user };
},
RESET_STATE(state) {
Object.assign(state, getDefaultState());
},
},
And the actual operations that will use those are actions. This is where you’ll contact your backend as well.
actions: {
async login({ commit }, authDetails) {
const { data } = await apolloClient.mutate({
mutation: LOGIN_USER,
variables: { ...authDetails },
});
const token = data.login.uuid;
commit("LOGIN_USER", data.login.user);
commit("SET_TOKEN", token);
localStorage.setItem("apollo-token", token);
onLogin(apolloClient);
},
async logout({ commit }, token) {
if (token) {
const status = await apolloClient.mutate({
mutation: LOGOUT_USER,
variables: { sessionUuid: token },
});
if (!status.data.logout) console.log("Logout returned false");
}
localStorage.removeItem("apollo-token");
commit("RESET_STATE");
refreshApollo(apolloClient);
},
},
For example we contact our backend using graphql here. Using localStorage here might look weird, but it’s easier that way for Apollo to apply necessary headers. Next step is to map those actions in our components where we’ll use them. This will create functions in the component and make actions easier to use.
...mapActions(["login"]),
loginUser() {
const authDetails = {
username: this.username,
password: this.password,
};
this.login(authDetails)
.then(() => this.$router.replace(
this.$route.query.redirect || "/home"))
.catch((err) => (this.formError = err.toString()));
},
Same goes for a logout action:
methods: {
...mapActions(["logout"]),
logoutUser() {
this.logout(this.token).then(() =>
this.$router.push("/pages/login"));
},
},
computed: {
...mapGetters(["token"]),
},
Now we’ll place a function in the router that’ll be run before each navigation and check if the user can go there. This is effectively a route guard and is called as such.
router.beforeEach(async (to, from, next) => {
// Wait vuex store
await store.restored;
// Check if the user is logged in
const isUserLoggedIn = store.getters.isAuthenticated;
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!isUserLoggedIn) {
// User is not logged in but trying to open page which requires
store.dispatch("logout");
next({
path: "/pages/login",
query: { redirect: to.fullPath },
});
} else {
// User is logged in and trying to open page which requires auth
next();
}
} else if (to.name === "Login" && isUserLoggedIn) {
// User logged in but still trying to open login page
next({ name: "Home" });
} else {
// User is trying to open page which doesn't require auth
next();
}
});
This completes the basic authentication but leaves a few crucial elements out. First one is the fact that our vuex store is actually in memory, and it won’t survive a browser refresh. Apollo token would, but not the entire store.
To do that we need another library. And it’s called vuex-persistedstate. After adding it to our dependencies all we have to do is define our store as persisted.
// To prevent reset state of vuex store on page refresh
import createPersistedState from "vuex-persistedstate";
...
const store = new Vuex.Store({
...
plugins: [
...
// To prevent reset state of vuex store on page refresh
createPersistedState({
storage: localStorage,
}),
],
});
We don’t need to change anything else. That’s where we first benefit from using vuex or a state manager. If we’d used a basic object and then decided to add a local storage extension it’d probably be more than a single line commit.
Next problem occurs if and when we use more than one browser tab. The state in different tabs are not the same even if we persist it to the local storage. So it’s possible to say logout in one tab and still be able to use the application on other tabs. The backend will probably complain but ideally it should be handled before that.
And guess what? Yes, we’ll have another plugin just to do that. Same drill, add dependency and modify the store definition, with the addition of defining the mutations which we want to share across the tabs.
// To share mutations of vuex store on multiple tabs/windows
import createMutationsSharer from "vuex-shared-mutations";
...
const store = new Vuex.Store({
...
plugins: [
...
// To share mutations on multiple tabs/windows
createMutationsSharer({
predicate: (mutation, state) => {
const predicate = [
"SET_TOKEN",
"LOGIN_USER",
"RESET_STATE",
];
return predicate.indexOf(mutation.type) >= 0;
},
}),
],
});
And That’s pretty much it. Happy hacking!