Vuex action does not work properly in a vue mounted hook - vue.js

I'm building a small e-commerce store with an admin panel for myself.
I use Firebase firestore as my backend to store all the user's data.
I have a root 'users' collection with a document for every single registered user and everything else each user has is branching out of the user doc.
Here are firestore commands i perform so you understand the structure better.
db.collection('users').doc(userId).collection('categories').doc(subCategoryId)...
db.collection('users').doc(userId).collection('subcategories').doc(subCategoryId)...
I use Vuex so every time i need to change something on my firestore (update a product category, remove a category etc.), i dispatch an appropriate action.
The first thing any of those actions does is to go ahead and dispatch another action from auth.js that gets the userId.
The problem is that if the action in question should run in a mounted() lifecycle hook, then it fails to grab the userId.
In EditCategory.vue updateCategory action works perfectly well because SubmitHandler() is triggered on click event but in Categories.vue the fetchCategories does not work and spit out an error:
[Vue warn]: Error in mounted hook (Promise/async): "FirebaseError: [code=invalid-argument]: Function CollectionReference.doc() requires its first argument to be of type non-empty string, but it was: null"
Function CollectionReference.doc() requires its first argument to be of type non-empty string, but it was: null
Which, as far as i understand it, basically tells me that fetchCategories() action's firestore query could not be performed because the userId was not recieved.
After two days of moving stuff around i noticed that errors only occur if i refresh the page. If i switch to other tab and back on without refreshing, then fetchCategories() from Categories.vue mounted() hook works. Placing the code in to created() hook gives the same result.
I think that there is some fundamental thing i am missing about asynchronous code and lifecycle hooks.
Categories.vue component
<template>
<div class="category-main">
<section>
<div class="section-cols" v-if="!loading">
<EditCategory
v-on:updatedCategory="updatedCategory"
v-bind:categories="categories"
v-bind:key="categories.length + updateCount"
/>
</div>
</section>
</div>
</template>
<script>
import EditCategory from '#/components/admin/EditCategory.vue'
export default {
name: 'AdminCategories',
components: {
EditCategory,
},
data: () => ({
updateCount: 0,
loading: true,
categories: [],
}),
async mounted() {
this.categories = await this.$store.dispatch('fetchCategories');// FAILS!
this.loading = false;
},
methods: {
addNewCategory(category) {
this.categories.push(category);
},
updatedCategory(category) {
const catIndex = this.categories.findIndex(c => c.id === category.id);
this.categories[catIndex].title = category.title;
this.categories[catIndex].path = category.path;
this.updateCount++;
}
}
}
</script>
category.js store file
import firebase, { firestore } from "firebase/app";
import db from '../../fb';
export default {
actions: {
async getUserId() {
const user = firebase.auth().currentUser;
return user ? user.uid : null;
},
export default {
state: {
test: 10,
categories: [],
subCategories: [],
currentCategory: '',
},
mutations: {
setCategories(state, payload){
state.categories = payload;
},
},
actions: {
async fetchCategories({commit, dispatch}) {
try {
const userId = await dispatch('getUserId');
const categoryArr = [];
await db.collection('users').doc(userId).collection('categories').get().then((querySnapshot) => {
querySnapshot.forEach((doc) => {
categoryArr.push({ id: doc.id, ...doc.data() })
});
})
commit('setCategories', categoryArr);
return categoryArr;
} catch (err) { throw err; }
},
async updateCategory({commit, dispatch}, {title, path, id}) {
try {
const userId = await dispatch('getUserId');
console.log('[category.js] updateCategory', userId);
await db.collection('users').doc(userId).collection('categories').doc(id).update({
title,
path
})
commit('rememberCurrentCategory', id);
return;
} catch (err) {throw err;}
}
},
}
auth.js store file
import firebase, { firestore } from "firebase/app";
import db from '../../fb';
export default {
actions: {
...async login(), async register(), async logout()
async getUserId() {
const user = firebase.auth().currentUser;
return user ? user.uid : null;
},
},
}
index.js store file
import Vue from 'vue'
import Vuex from 'vuex'
import auth from './auth'
import products from './products'
import info from './info'
import category from './category'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
auth, products, info, category,
}
})
EditCategory.vue
export default {
name: 'EditCategory',
data: () => ({
select: null,
title: '',
path: '',
current: null
}),
props: {
categories: {
type: Array,
required: true
}
},
methods: {
async submitHandler() {
if (this.$v.invalid){
this.$v.$touch()
return;
}
try {
const categoryData = {
id : this.current,
title: this.title,
path: this.path
};
await this.$store.dispatch('updateCategory', categoryData);// WORKS!
this.$emit('updatedCategory', categoryData);
} catch (err) { throw err; }
},
},
//takes current category id from store getter
computed: {
categoryFromState() {
return this.$store.state.currentCategory;
}
},
created() {
console.log('[EditCategory.vue'], currentCategory);
},
mounted(){
this.select = M.FormSelect.init(this.$refs.select);
M.updateTextFields();
},
destroyed() {
if (this.select && this.select.destroy) {
this.select.destroy;
}
}
}
</script>

First of all, it's just a small detail, but you don't need need to make your 'getUserId' action async, since it does not use the 'await' keyword. So can simplify this :
async getUserId() {
const user = firebase.auth().currentUser;
return user ? user.uid : null;
}
const userId = await dispatch('getUserId')
into this :
getUserId() {
const user = firebase.auth().currentUser;
return user ? user.uid : null;
}
const userId = dispatch('getUserId')
Coming back to your id that seems to be undefined, the problem here is that your 'mounted' event is probably triggered before the 'login' can be completed.
How to solve this case ? Actually, there are a lot of different ways to approch this. What I suggest in your case is to use a middleware (or a 'route guard'). This guard can make you are verified user before accessing some routes (and eventually restrict the access or redirect depending on the user privileges). In this way, you can make sure that your user is defined before accessing the route.
This video is 4 years old so it is not up to date with the last versions of Firebas. But I suggest The Net Ninja tutorial about Vue Route Guards with Firebase if you want to learn more about this topic.

Accepted answer actually pointed me to the correct direction.
In my case i had to make a route guard for child routes.
router.vue
import Vue from 'vue'
import Router from 'vue-router'
import firebase from 'firebase/app';
Vue.use(Router);
const router = new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'home',
meta: {layout: 'main-layout'},
component: () => import('./views/main/Home.vue')
},
{
path: '/bouquets',
name: 'bouquets',
meta: {layout: 'main-layout'},
component: () => import('./views/main/Bouquets.vue')
},
{
path: '/sets',
name: 'sets',
meta: {layout: 'main-layout'},
component: () => import('./views/main/Sets.vue')
},
{
path: '/cart',
name: 'cart',
meta: {layout: 'main-layout'},
component: () => import('./views/main/Cart.vue')
},
{
path: '/login',
name: 'login',
meta: {layout: 'empty-layout'},
component: () => import('./views/empty/Login.vue')
},
{
path: '/register',
name: 'register',
meta: {layout: 'empty-layout'},
component: () => import('./views/empty/Register.vue')
},
{
path: '/admin',
name: 'admin',
meta: {layout: 'admin-layout', auth: true},
component: () => import('./views/admin/Home.vue'),
children: [
{
path: 'categories',
name: 'adminCategories',
meta: {layout: 'admin-layout', auth: true},
component: () => import('./views/admin/Categories'),
},
{
path: 'subcategories',
name: 'adminSubcategories',
meta: {layout: 'admin-layout', auth: true},
component: () => import('./views/admin/SubCategories'),
},
{
path: 'products',
name: 'adminProducts',
meta: {layout: 'admin-layout', auth: true},
component: () => import('./views/admin/Products'),
},
]
},
{
path: '/checkout',
name: 'checkout',
meta: {layout: 'main-layout'},
component: () => import('./views/main/Checkout.vue')
},
{
path: '/:subcategory',
name: 'subcategory',
meta: {layout: 'main-layout'},
props: true,
params: true,
component: () => import('./views/main/Subcategory.vue')
},
]
})
router.beforeEach((to, from, next) => {
//if currentUser exists then user is logged in
const currentUser = firebase.auth().currentUser;
//does a route has auth == true
const requireAuth = to.matched.some(record => record.meta.auth);
//if auth is required but user is not authentificated than redirect to login
if (requireAuth && !currentUser) {
// next('/login?message=login');
next('login')
} else {
next();
}
})
export default router;
category.js fetchCategories() action
async fetchCategories({commit, dispatch}) {
const userId = await dispatch('getUserId')
try {
const categoryArr = [];
await db.collection('users').doc(userId).collection('categories').get().then((querySnapshot) => {
querySnapshot.forEach((doc) => {
categoryArr.push({ id: doc.id, ...doc.data() })
});
})
commit('setCategories', categoryArr);
return categoryArr;
} catch (err) { throw err; }
},

Related

Vue 3, SocketIO - won't join the room after login and router push

I am experiencing the following issue - once the user is logged in, and onMounted event is finished, SocketIO client side should join the room. But this doesn't happen for some reason. I have to manually refresh browser in order to join the room. What am I doing wrong here?
I have the following code for the SocketIO on the client side:
import { io } from "socket.io-client";
const token = window.localStorage.getItem('TOKEN') || window.sessionStorage.getItem('TOKEN')
console.log(token);
const ioSocket = io('https://dolphin-app-e4ozn.ondigitalocean.app', {
withCredentials: true,
transportOptions: {
polling: {
extraHeaders: {
'authorization': `${token}`,
},
},
},
});
export const socket = ioSocket
The vue 3 router logic:
import { createRouter, createWebHistory } from 'vue-router'
import Landing from '#/views/Landing.vue'
import Login from '#/views/login/Login.vue'
import ResetPassword from '#/views/login/ResetPassword.vue'
import ForgotPassword from '#/views/login/ForgotPassword.vue'
const routes = [
{
path: '/login',
name: 'login',
component: Login,
meta: {
isGuest: true,
title: 'Servant | Login'
}
},
{
path: '/resetPassword',
name: 'resetPassword',
component: ResetPassword,
meta: {
isGuest: true,
title: 'Servant | Reset Password'
}
},
{
path: '/forgotPassword',
name: 'forgotPassword',
component: ForgotPassword,
meta: {
isGuest: true,
title: 'Servant | Forgot Password'
}
},
{
path: '/',
name: 'landing',
component: Landing,
meta: {
requiresAuth: true,
title: 'Servant',
role: 'waiter'
}
},
{
path: '/:pathMatch(.*)*',
component: Landing
},
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
// always scroll to top
return { top: 0 }
},
})
router.beforeEach((to, from, next) => {
document.title = to.meta.title || "Servant"
let token = window.localStorage.getItem('TOKEN') || window.sessionStorage.getItem('TOKEN')
if(to.meta.requiresAuth && !token)
{
next({name: 'login'})
}
if (token && to.meta.isGuest )
{
next({ name: 'landing' })
}
next();
});
export default router
Login component logic:
function login() {
loading.value = true
formClass = ''
if (user.remember)
{
window.localStorage.setItem('remember', user.remember)
}
mainStore
.login(user)
.then((response) => {
loading.value = false
router.push({
name: 'landing',
})
})
.catch((err) => {
loading.value = false
errorMsg.value = err.response.data.messages.error
formClass = 'was-validated'
})
}
Once the component is mounter I have following logic:
onMounted(() => {
socket.emit("join", import.meta.env.VITE_SOCKET_ROOM, (message) => {
console.log(message);
});
})
On the SocketIO server side I have following logic:
io.use((socket, next) => {
const header = socket.handshake.headers["authorization"];
if(header !== 'null')
{
jwtVerify(header, secret).then((res) => {
if (res === true) {
const jwt = jwtDecode(header);
servantID = jwt.payload.iss;
return next();
}
return next(new Error("authentication error"));
});
}
});

how to auth the user in vue 3?

I am having some issue login the user in my app using vue 3 (vue-cli) and vue-router 4
This is the router.js
import { createRouter, createWebHistory } from 'vue-router';
import store from '../store';
import AuthLayout from "../layouts/AuthLayout";
import DashboardLayout from "../layouts/DashboardLayout";
import PageNotFound from "../views/errors/PageNotFound";
import Login from "../views/auth/Login";
import Logout from "../views/auth/Logout"
import Dashboard from "../views/dashboard/Dashboard";
let routes = [
{
path: '/',
redirect: 'dashboard',
component: DashboardLayout,
children: [
{
path: '/',
component: Dashboard,
name: 'dashboard',
meta: {
requiresAuth: true
}
},
{
path: '/logout',
component: Logout,
name: 'logout',
meta: {
requiresAuth: true
}
},
{
path: '/:pathMatch(.*)*',
component: PageNotFound,
name: 'page-not-found',
meta: {
requiresAuth: true
}
}
]
},
{
path: '/',
redirect: 'login',
component: AuthLayout,
children: [
{
path: '/login',
component: Login,
name: 'login',
meta: {
requiresVisitor: true
}
},
]
}
];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes: routes,
linkExactActiveClass: 'active'
});
// eslint-disable-next-line no-unused-vars
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !store.state.authenticated) {
return {
name: 'login',
}
}
})
export default router;
I am importing the store in routes to access the state authenticated. When the server auth the user then authenticated = true.
The authenticated (state from vuex) ... it is true now... but it stays at login form. If I force to go in / (dashboard) it return again to login.
The user is logged in.
Anyone have an idea what I am missing in routes.js ???
***** UPDATE *****
Login component
export default {
name: "Login.vue",
setup() {
const axios = inject('$axios')
const store = useStore()
const authenticated = computed(() => store.state.authenticated)
const auth = ref( {
email: '',
password: ''
})
const authUser = () => {
axios.get('/api/user').then(response => {
store.commit('setAuthenticated',true);
store.commit('setUser', response.data)
})
}
const handleLogin = () => {
// eslint-disable-next-line no-unused-vars
axios.post('/login', auth.value).then(response => {
authUser()
}).catch(error => {
console.log(error)
})
}
onMounted(() => {
// eslint-disable-next-line no-unused-vars
axios.get('/sanctum/csrf-cookie').then(response => {
authUser()
})
})
return { auth, handleLogin, authenticated }
}
}
The issue, I believe, is that the authentication state is not persistent. That means, that the data is gone if you redirect (using a manual url change) or refresh.
You can add persistence by
const state = {
authenticated: localStorage.getItem('authenticated')==='true'; // get authentication from local storage
}
const store = createStore({
state: state,
mutations: {
setAuthenticated(state, payload) {
state.authenticated = payload;
localStorage.setItem('authenticated', payload); // sill store 'true' in local storage
}
}
})
This will store the authenticated state in your localStorage. It populates the store state.authenticated value on instantiation, and updates on change.
There's some other considerations, such as redirecting
const authUser = () => {
axios.get('/api/user').then(response => {
store.commit('setAuthenticated',true);
store.commit('setUser', response.data);
router.push('/'); // <= will redirect after the values are set
})
}

Is there a way to detect query changes using Vue-Router and successfully get new data?

I am simply making an asynchronous request to get data about a MLB player but am failing to get new data by manually changing the query parameters in the URL. When I use watch, the from and the to are the same for some reason upon debugging with Vue dev tools. However, all works well when I manually click a link to navigate routes as the from and the to correctly represent the from and the to routes.
PitcherForm.vue
export default {
name: "PitcherForm",
components: {
PlayerForm,
},
watch: {
$route() {
this.handleSubmit({ firstName: this.$route.query.firstName, lastName: this.$route.query.lastName });
}
},
methods: {
handleSubmit: function(formValues) {
// this.$store.dispatch("fetchPlayer", { formValues, router: this.$router, player: "pitching" });
this.$store
.dispatch("fetchPlayer", { formValues, player: "pitching" })
.then((promiseObject) => {
console.log(promiseObject)
this.$router.push({
// name: 'PitcherData',
path: "pitching/player",
query: {
firstName: promiseObject.firstName,
lastName: promiseObject.lastName,
},
});
})
.catch((error) => {
console.log(error);
});
},
},
//store.js
import Vue from 'vue';
import Vuex from 'vuex';
import VuexPersist from 'vuex-persist';
import axios from 'axios';
Vue.use(Vuex);
// VuexPersist stuff
const vuexLocalStorage = new VuexPersist({
key: 'vuex',
storage: window.localStorage,
});
export const store = new Vuex.Store({
plugins: [vuexLocalStorage.plugin],
state: {
playerStats: []
},
// mutations, getters, excluded for convenience
actions: {
fetchPlayer({ commit }, data) {
return new Promise((resolve) => {
let firstName = data.formValues.firstName.replace(/\s/g, "").toLowerCase();
let lastName = data.formValues.lastName.replace(/\s/g, "").toLowerCase();
axios.get(`http://localhost:5000/${data.player}/player`,
{
params: {
first: firstName.charAt(0).toUpperCase() + firstName.slice(1),
last: lastName.charAt(0).toUpperCase() + lastName.slice(1),
},
}).then(response => {
commit('setPlayers', response.data);
}).catch((error) => {
console.log(error);
});
resolve({firstName, lastName});
});
}
}
});

Vue Router catch 401 on route changes and destroy local storage JWT

Having a dashboard app created with vuejs and vue-router most of my routes are requiring authentication and I want to catch globally if token is expired and route back in that case to login component.
Right now router looks as it follows
import Vue from 'vue'
import VueRouter from 'vue-router'
import AttendersView from '#/views/AttendersView.vue'
import LoginView from '#/views/LoginView.vue'
import store from '../store'
import log from '#/middleware/log'
Vue.use(VueRouter)
const routes = [
{
path: '*',
meta: {
name: '',
requiresAuth: true
},
redirect: {
path: '/attenders'
}
},
{
path: '/login',
component: LoginView,
meta: {
guest: true
},
children: [
{
path: '',
component: () => import('#/components/LoginForm.vue')
}
]
},
{
path: '/',
meta: {
name: 'Dashboard View',
requiresAuth: true
},
component: () => import('#/views/DashboardView.vue'),
children: [
{
path: '/attenders',
name: 'Anmeldungen',
component: AttendersView,
meta: {
requiresAuth: true,
middleware: log
}
},
{
path: '/organizations',
name: 'Verbände',
meta: {
requiresAuth: true,
middleware: log
},
component: () => import(/* webpackChunkName: "about" */ '../views/OrganizationsView.vue')
},
{
path: '/workgroups',
name: 'Arbeitsgruppen',
meta: {
requiresAuth: true,
middleware: log
},
component: () => import(/* webpackChunkName: "about" */ '../views/WorkgroupsView.vue')
},
{
path: '/status',
name: 'Status',
meta: {
requiresAuth: true,
middleware: log
},
component: () => import(/* webpackChunkName: "about" */ '../views/StatusView.vue')
}
]
}
]
const router = new VueRouter({
mode: 'history',
base: 'dashboard',
routes,
scrollBehavior (to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
if (to.hash) {
return { selector: to.hash }
}
return { x: 0, y: 0 }
}
})
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
if (store.getters.authorized) {
next()
return
}
next('/login')
} else {
next()
}
})
export default router
and here is my custom request.js
import axios from 'axios'
class HttpClient {
constructor (token) {
if (localStorage.getItem('token')) {
token = localStorage.getItem('token')
}
const service = axios.create({
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
this.service = service
}
redirectTo = (document, path) => {
document.location = path
}
get (path) {
return this.service.get(path)
}
patch (path, payload, callback) {
return this.service
.request({
method: 'PATCH',
url: path,
responseType: 'json',
data: payload
})
}
post (path, payload) {
return this.service.request({
method: 'POST',
url: path,
responseType: 'json',
data: payload
})
}
delete (path, payload) {
return this.service.request({
method: 'DELETE',
url: path,
responseType: 'json',
data: payload
})
}
download (path) {
return this.service.request({
method: 'POST',
url: path,
responseType: 'blob'
})
}
}
const HttpRequests = new HttpClient()
export default HttpRequests
and right now I'm doing like this in component to catch 401
methods: {
fetchInitialData: function () {
this.isLoading = true
HttpClient.get(API_ATTENDERS_ENDPOINT).then(resp => {
this.attenders = resp.data.attenders
this.organizations = resp.data.organizations
this.workgroups = resp.data.workgroups
this.isLoading = false
}).catch(error => {
if (error.response.status === 401) {
this.$store.dispatch('logout')
}
})
}
but I need something generic.
What is the best approach and where should I place an axios interceptor ?
What you can do is use navigation guards for this task: https://router.vuejs.org/guide/advanced/navigation-guards.html#global-before-guards
Before the user enters any of the routes you want Authentication attached to then you can have a before enter, and then check their privileges.
Pass then on with a next() if they succeed or throw them to the login.

loggedIn state reverts back to false after Logging in and doesn't allow me to guard route

I'm trying to guard my routes with state: { loggedIn: false }, when I login from my Login.vue component the goal is to trigger an action this.$store.dispatch('setLogin') that mutates the state of loggedIn to true. There is then navigation guard that is suppose to prevent me form seeing my Login.vue and Regester.vue components. The problem is that it seems like the state changes to true, but not the base state: allowing me to keep hitting the /auth/login and /auth/register routes.
Routes
const routes = [
{
path: '/auth',
name: 'auth',
component: Auth,
children: [
{ name: 'login', path: 'login', component: Login },
{ name: 'register', path: 'register', component: Register },
],
meta: {
requiresVisitor: true,
}
},
{
path: '/logout',
name: 'logout',
component: Logout
}
]
Login Component
login() {
this.$http.get('/sanctum/csrf-cookie').then(response => {
this.$http.post('/login', {
email: this.username,
password: this.password,
}).then(response2 => {
this.$store.dispatch('setLogin')
this.$store.dispatch('getUser')
alert(this.$store.state.loggedIn)
this.$router.push({ name: 'Home' })
}).catch(error => {
console.log(error.response.data);
const key = Object.keys(error.response.data.errors)[0]
this.errorMessage = error.response.data.errors[key][0]
})
});
}
Vuex
export default new Vuex.Store({
state: {
loggedIn: false,
user: JSON.parse(localStorage.getItem('user')) || null,
},
mutations: {
setLogin: (state) => {
state.loggedIn = true
},
SET_USER_DATA (state, userData) {
localStorage.setItem('user', JSON.stringify(userData))
state.user = userData;
},
removeUser(state) {
localStorage.removeItem('user');
state.user = null;
}
},
actions: {
getUser(context) {
if (context.state.loggedIn) {
alert('hit');
return new Promise((resolve, reject) => {
axios.get('api/user')
.then(response => {
context.commit('SET_USER_DATA', response.data.data)
resolve(response)
})
.catch(error => {
reject(error)
})
})
}
},
setLogin(context){
context.commit('setLogin')
}
},
modules: {
}
})
It's strange because alert(this.$store.state.loggedIn) renders true, but when I go back the auth link there's a mounted state alert that comes back false.
Here's my navigation guards as well:
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!store.state.loggedIn) {
next({
name: 'login',
})
} else {
next()
}
} else if (to.matched.some(record => record.meta.requiresVisitor)) {
if (store.state.loggedIn) {
next({
name: 'Home',
})
return
} else {
next()
}
} else {
next()
}
})
You need to store the loggedIn user in local storage:
setLogin: (state) => {
state.loggedIn = localStorage.setItem('loggedIn', 'true')
state.loggedIn = true
},
Then your state should look like:
state: {
loggedIn: localStorage.getItem('loggedIn') || null,
},