Why is firebase authentication not persisting on refresh in Ionic with Vue and Pinia?

Issue

I’ve been generally following along with this code here: https://github.com/aaronksaunders/ionic-v6-firebase-tabs-auth

The issue I’m having is my auth state is not persisting when I refresh the page when using ionic serve and loading the app in the web browser.

code for pinia store:

import { defineStore } from "pinia";
import { User } from "firebase/auth";
import {
  Profile,
  getProfile,
  setProfile,
} from "@/firebase/helpers/firestore/profileManager";
import { onSnapshot, Unsubscribe, doc } from "@firebase/firestore";
import { db } from "@/firebase/connectEmulators";
import { getAuth } from "@firebase/auth";
import {
  onAuthStateChanged,
  signInWithEmailAndPassword,
  signOut,
  createUserWithEmailAndPassword,
  updateProfile as updateAuthProfile,
} from "firebase/auth";
import errorHandler from "@/helpers/errorHandler";

/**@see {@link Profile} */
export enum UserType {
  DNE,
  uploader,
  checker,
  host,
}

interface State {
  user: User | null;
  profile: Profile | null;
  error: null | any;
  unsub: Unsubscribe | null;
}

export const useUserStore = defineStore("user", {
  state: (): State => {
    return {
      user: null,
      profile: null,
      error: null,
      unsub: null,
    };
  },
  getters: {
    isLoggedIn: (state) => state.user !== null,
    //DEV: do we need this to be a getter?
    userError: (state) => {
      if(state.error){
        switch (state.error.code) {
          case "auth/user-not-found":
            return "Email or Password incorrect!";
          case "auth/wrong-password":
            return "Email or Password incorrect!";
          default:
            return state.error;
        }
      }
      return null;
    },
    /**
     * @see Profile
     */
    getType: (state): UserType => {
      if (state.user === null) return UserType.DNE;
      if (!state.profile) return UserType.DNE;
      if (state.profile.locations.length > 0) return UserType.host;
      if (state.profile.queues.length > 0) return UserType.checker;
      return UserType.uploader;
    },
  },
  actions: {
    initializeAuthListener() {
      return new Promise((resolve) => {
        const auth = getAuth();
        onAuthStateChanged(auth, (user) => {
          console.log("AuthListener Initialized");
          if (user) {
            console.log("AuthListener: User exists!");
            this.user = user;

            getProfile(user.uid).then((profile) => {
              if (profile) {
                this.profile = profile;
                this.initializeProfileListener();
              } else {
                this.profile = null;
                if (this.unsub) this.unsub();
              }
            });
          } else {
            console.log("AuthListener: User does not exist!");
            this.user = null;
          }
          resolve(true);
        });
      });
    },
    /**
     *
     * @param email email for login
     * @param password password for login
     */
    async signInEmailPassword(email: string, password: string) {
      try {
        const auth = getAuth();
        const userCredential = await signInWithEmailAndPassword(
          auth,
          email,
          password
        );
        this.user = userCredential.user ? userCredential.user : null;
        this.error = null;
        return true;
      } catch (error: any) {
        console.log(typeof error.code);
        console.log(error.code);
        this.user = null;
        this.error = error;
        return false;
      }
    },
    async logoutUser() {
      try {
        const auth = getAuth();
        await signOut(auth);
        this.user = null;
        this.profile = null;
        this.error = null;
        if (this.unsub) this.unsub();
        return true;
      } catch (error: any) {
        this.error = error;
        return false;
      }
    },
    async createEmailPasswordAccount(
      email: string,
      password: string,
      userName: string,
      refSource: string
    ) {
      try {
        const auth = getAuth();
        const userCredential = await createUserWithEmailAndPassword(
          auth,
          email,
          password
        );
        //Add username to fireauth profile
        //DEV: test for xss vulnerabilities
        await updateAuthProfile(userCredential.user, { displayName: userName });

        //create user profile data in firestore
        let profile: Profile | undefined = new Profile(
          userCredential.user.uid,
          refSource
        );
        await setProfile(profile);
        profile = await getProfile(userCredential.user.uid);
        //set local store
        this.user = userCredential.user ? userCredential.user : null;
        this.profile = profile ? profile : null;
        this.error = null;
        //TODO: send email verification
        return true;
      } catch (error: any) {
        this.user = null;
        this.error = error;
        return false;
      }
    },
    initializeProfileListener() {
      try {
        if (!this.profile) errorHandler(Error("Profile not set in state!"));
        else {
          const uid = this.profile.uid;
          const unsub: Unsubscribe = onSnapshot(
            doc(db, "profiles", uid),
            (snapshot) => {
              const fbData = snapshot.data();
              if (!fbData)
                errorHandler(Error("Profile Listener snapshot.data() Null!"));
              else {
                const profile = new Profile(
                  snapshot.id,
                  fbData.data.referralSource
                );
                profile.data = fbData.data;
                profile.settings = fbData.settings;
                profile.locations = fbData.locations;
                profile.queues = fbData.queues;
                profile.checkers = fbData.checkers;
                profile.uploadHistory = fbData.uploadHistory;
                profile.hostLevel = fbData.hostLevel;
                this.profile = profile;
              }
            },
            (error) => {
              errorHandler(error);
            }
          );
          this.unsub = unsub;
        }
      } catch (error) {
        errorHandler(error as Error);
      }
    },
  },
});

main.ts where I initialize auth listener:

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";

import { IonicVue } from "@ionic/vue";

/* Core CSS required for Ionic components to work properly */
import "@ionic/vue/css/core.css";

/* Basic CSS for apps built with Ionic */
import "@ionic/vue/css/normalize.css";
import "@ionic/vue/css/structure.css";
import "@ionic/vue/css/typography.css";

/* Optional CSS utils that can be commented out */
import "@ionic/vue/css/padding.css";
import "@ionic/vue/css/float-elements.css";
import "@ionic/vue/css/text-alignment.css";
import "@ionic/vue/css/text-transformation.css";
import "@ionic/vue/css/flex-utils.css";
import "@ionic/vue/css/display.css";

/* Theme variables */
import "./theme/variables.css";

/* PWA elements for using Capacitor plugins */
import { defineCustomElements } from "@ionic/pwa-elements/loader";

/* Pinia used for state management */
import { createPinia } from "pinia";
import { useUserStore } from "./store/userStore";
const pinia = createPinia();

const app = createApp(App)
  .use(IonicVue, {
    // TODO: remove for production
    mode: process.env.VUE_APP_IONIC_MODE,
  })
  .use(pinia);

defineCustomElements(window);

//get the store
const store = useUserStore();

store.initializeAuthListener().then(() => {
  app.use(router);
});

router.isReady().then(() => {
  app.mount("#app");
});

I’ve tried refactoring main.ts to mount the app inside the callback for initialize auth listener and I’ve tried making my code exactly like the code in the main.ts of the above link. Neither solved the issue.

I also looked at the question here: https://stackoverflow.com/a/67774186/9230780
Most of the points in the answer shouldn’t be related because I’m currently using the firebase emulators to test the app.

Even still, I’ve verified my api key is correct.
I can see that cookies are created in the browser when I launch the app, so I don’t think its an issue with them being wiped.

Ideally I’d like to avoid implementing @capacitor/storage here because it shouldn’t be necessary.

I do plan to implement this library to handle authentication for ios and android: https://github.com/baumblatt/capacitor-firebase-auth
but that shouldn’t be pertinent to the web version of the app.

Edit:
Realized I was missing a piece of code pertinent to the question. Not sure how I didn’t copy it over. Code added is the initialize Profile listener function.

Solution

I ended up doing a refactor of the pinia store and that solved the issue. I believe the issue may have been caused by how the auth listener called initializeProfileListener. I didn’t have code in the auth listener to check if the profile listener was already initialized, so everytime the authstate changed or it would initialize a new profile listener without unsubbing the old one. I’m not absolutely certain that is what was causing the issue though.

Below is the new code that functions properly.
pinia store:

import { defineStore } from "pinia";
import { User } from "firebase/auth";
import {
  Profile,
  getProfile,
  profileListener,
} from "@/firebase/helpers/firestore/profileManager";
import {
  fbCreateAccount,
  fbSignIn,
  fbAuthStateListener,
  fbSignOut,
} from "@/firebase/helpers/firestore/authHelper";
import {Unsubscribe} from "@firebase/firestore";
import errorHandler from "@/helpers/errorHandler";

/**@see {@link Profile} */
export enum UserType {
  DNE,
  uploader,
  checker,
  host,
}

interface State {
  user: User | null;
  profile: Profile | null;
  error: null | any;
  unsub: Unsubscribe | null;
}

export const useUserStore = defineStore("user", {
  state: (): State => {
    return {
      user: null,
      profile: null,
      error: null,
      unsub: null,
    };
  },
  getters: {
    isLoggedIn: (state) => state.user !== null,
    //DEV: do we need this to be a getter?
    userError: (state) => {
      if (state.error) {
        switch (state.error.code) {
          case "auth/user-not-found":
            return "Email or Password incorrect!";
          case "auth/wrong-password":
            return "Email or Password incorrect!";
          default:
            return state.error;
        }
      }
      return null;
    },
    /**
     * @see Profile
     */
    getType: (state): UserType => {
      if (state.user === null) return UserType.DNE;
      if (!state.profile) return UserType.DNE;
      if (state.profile.locations.length > 0) return UserType.host;
      if (state.profile.queues.length > 0) return UserType.checker;
      return UserType.uploader;
    },
  },
  actions: {
    initializeAuthListener() {
      return new Promise((resolve) => {
        fbAuthStateListener(async (user: any) => {
          if (user) {
            this.user = user;
            const profile = await getProfile(user.uid);
            if (profile) {
              this.profile = profile;
              //TODO: initialize profile listener
              if(this.unsub === null) {
                this.initializeProfileListener();
              }
            }
          }
          resolve(true);
        });
      });
    },
    /**
     *
     * @param email email for login
     * @param password password for login
     */
    async signInEmailPassword(email: string, password: string) {
      try {
        const userCredential = await fbSignIn(email, password);
        this.user = userCredential.user ? userCredential.user : null;
        this.error = null;
        return true;
      } catch (error: any) {
        console.log(typeof error.code);
        console.log(error.code);
        this.user = null;
        this.error = error;
        return false;
      }
    },
    async logoutUser() {
      try {
        await fbSignOut();
        this.user = null;
        this.profile = null;
        this.error = null;
        if (this.unsub) this.unsub();
        return true;
      } catch (error: any) {
        this.error = error;
        return false;
      }
    },
    async createEmailPasswordAccount(
      email: string,
      password: string,
      userName: string,
      refSource: string
    ) {
      try {
        const { user, profile } = await fbCreateAccount(
          email,
          password,
          userName,
          refSource
        );
        //set local store
        this.user = user ? user : null;
        this.profile = profile ? profile : null;
        this.error = null;
        //TODO: send email verification
        return true;
      } catch (error: any) {
        this.user = null;
        this.error = error;
        return false;
      }
    },
    initializeProfileListener() {
      try {
        if (this.user) {
          const unsub = profileListener(
            this.user?.uid,
            async (profile: any) => {
              if (profile) {
                this.profile = profile;
              }
            }
          );
          this.unsub = unsub;
        }
      } catch (error) {
        errorHandler(error as Error);
      }
    },
  },
});

authHelper.ts

import { auth } from "@/firebase/firebase";
import {
  createUserWithEmailAndPassword,
  signInWithEmailAndPassword,
  signOut,
  onAuthStateChanged,
  updateProfile as updateAuthProfile,
} from "@firebase/auth";
import { Profile, setProfile, getProfile } from "./profileManager";

/**
 * @param email
 * @param password
 * @param userName
 * @param refSource @see profileManager
 * @returns
 */
export const fbCreateAccount = async (
  email: string,
  password: string,
  userName: string,
  refSource: string
) => {
  //DEBUG: creating a user works but throws an error.
  const userCredential = await createUserWithEmailAndPassword(
    auth,
    email,
    password
  );
  if (userCredential) {
    //add username to fireauth profile
    await updateAuthProfile(userCredential.user, { displayName: userName });
    //create user profile data in firestore
    let profile: Profile | undefined = new Profile(
      userCredential.user.uid,
      refSource
    );
    await setProfile(profile);
    profile = await getProfile(userCredential.user.uid);
    //TODO: errorHandling for setProfile and getProfile
    return {
      user: userCredential.user,
      profile: profile,
    };
  } else {
    return {
      user: null,
      profile: null,
    };
  }
};

/**
 *
 * @param email
 * @param password
 * @returns UserCredential {@link https://firebase.google.com/docs/reference/js/auth.usercredential.md?authuser=0#usercredential_interface}
 */
export const fbSignIn = async (email: string, password: string) => {
  const userCredential = signInWithEmailAndPassword(auth, email, password);
  //TODO: add call to add to profile signins array
  return userCredential;
};

export const fbSignOut = async () => {
  await signOut(auth);
  return true;
};

/**
 * @see {@link https://firebase.google.com/docs/reference/js/auth.md?authuser=0&hl=en#onauthstatechanged}
 * @param callback contains either user or null
 */
export const fbAuthStateListener = (callback: any) => {
  onAuthStateChanged(auth, (user) => {
    if (user) {
      //user is signed in
      callback(user);
    } else {
      //user is signed out
      callback(null);
    }
  });
};

Answered By – Nathan Goren

Answer Checked By – David Goodson (AngularFixing Volunteer)

Leave a Reply

Your email address will not be published.