Initialize Vue app after Firebase auth state changed

Viewed 5472

I have a bit of a conundrum in my Vue/vuex/vue-router + Firebase app. The bootstrap code is supposed to initialize Firebase, as well as the signed in user (if any) in Vuex:

new Vue({
  el: '#app', router, store, template: '<App/>', components: { App },
  beforeCreate() {
    firebase.initializeApp(firebaseConfig)
      // Because the code below is async, the app gets created before user is returned...
      .auth().onAuthStateChanged(user => {
        this.$store.commit('syncAuthUser', user) // this executes too late...
      })
  }
})

In my routes, I have

{ path: '/home', name: 'Home', component: Home, meta: { requiresAuth: true } }

as well as

router.beforeEach((to, from, next) => {
  let authUser = store.getters.authUser // this is not set just yet...

  if (to.matched.some(record => record.meta.requiresAuth)) {
    if (!authUser) {
      next('signin') // /home will always hit here...
    }
    next()
  }
  else {
    next()
  }
})

Problem

As mentioned in the comments, the app gets the authenticated user too late. So, when you go to /home while logged in, the app will run the beforeEachcheck in the routes... And the user instance will not be available just yet, so it will think that you are not logged in, when in fact you are (it's clear from my computed properties that are properly updated a few moments later).

Question

So, how do I change my code, so that the Vue app gets initialized only after Firebase returns the user and after that user is passed to the Vuex store? Plese let me know if I am doing something completely wrong.

Attempts

  1. I tried the "unsubscribe" approach; however, its problem is that the onAuthStateChanged would only be triggered once. So, if I put my crucial this.$store.commit('syncAuthUser', user) there, then that code would only execute when the app is created, and not when the user logs out for example.
  2. I also tried a better approach here. I did work for me, but I felt bad wrapping onAuthStateChanged in another Promise, for sure there must be a better way; plus, it seems wrong to place the auth user initialization login in the routes file.
2 Answers

The easiest way is to wait for the auth state before mounting your app:

const app = new Vue({
  router,
  store,
  render: h => h(App),
});

firebase.auth().onAuthStateChanged(user => {
  store.commit('authStateChanged', user);
  app.$mount('#app');
});

If you want to show a spinner before mount put it in #app:

<div id="app"><div class="loader">Loading...</div></div>

In fact it will make your actions cleaner and the app more reliable if you extract the auth state logic to a authStateChanged commit as above, and handle your route redirection here too. As this will handle if the auth state changes while you are on a route while router.beforeEach will check when you're going to one.

firebase.auth().onAuthStateChanged(user => {
  store.commit('auth/authStateChanged', user);

  const isAuthed = Boolean(user);
  const { meta } = router.history.current;

  if (isAuthed && meta.isPublicOnly) { // e.g. /login + /signup
    router.push('/dashboard');
  } else if (!isAuthed && meta.isPrivate) {
    router.push('/login');
  }

  app.$mount('#app');
});

Note that the first router.beforeEach callback happens before this, so you should ignore it until auth state has been determined. Since we handle the first redirection in onAuthStateChanged it doesn't matter.

state: {
  isInited: false,
  isAuthed: false,
},
mutations: {
  authStateChanged(state, user) {
    state.isAuthed = Boolean(user);
    state.isInited = true;
  }
}

router.beforeEach((to, from, next) => {
  const { isInited, isAuthed } = store.state.user;

  if (isInited) {
    if (!isAuthed && to.matched.some(record => record.meta.isPrivate)) {
      return next('/login');
    } else if (isAuthed && to.matched.some(record => record.meta.isPublicOnly)) {
      return next('/dashboard');
    }
  }

  next();
});

You could probably prevent the route loading at all until isInited is true alternatively.

EXAMPLE

./App.vue

created: function() {
  auth.onAuthStateChanged(user => {

    this.$store.dispatch("SET_USER", user)

    user.getIdToken().then( token => {
      this.$store.dispatch("SET_TOKEN", token)
    })
}

./store.js (mutations)

export default {
  SET_USER(state, user) {
    state.authenticated = !!user
    state.user = {
      email: user ? user.email : null,
      displayName: user ? user.displayName : null,
      photoURL: user ? user.photoURL : null,
    }
  }
  SET_TOKEN(state, payload) {
    state.token = payload
  }
}

OVERVIEW

LifeCycle

This example works using Vue's created: lifecycle hook. Within created: we use auth.onAuthStateChanged to spin up an event listener. Your app is now listening for auth changes from firebase.

auth.onAuthStateChanged(user => { ...listening for user objects... })

The user object is returned whenever Firebase notices an auth state change. Use its properties and methods for authentication and authorization.

Property: user.emailVerfied

Method: user.getIdToken()

TL;DR

When using Firebase onAuthStateChanged, you must implement Observable and Subscriptions objects. An RxJS Observable is asynchronous like a promise, and it will help you wait for the auth response from Firebase.

See the first argument, nextOrObserver from the documentation:

onAuthStateChanged(nextOrObserver, error, completed) returns function()

The Observable design pattern, (i.e., asynchronous, push, multicast) is documented here.

Related