0

I'm busy building a react app with firebase being used on the back-end. I've been building the app using react-router-dom and have recently created a protected route to prevent access to certain routes if you aren't logged in. I was wondering though if its at all possible to trigger a redirect to a specific route if a user tries to access a route before there email has been verified. I've been trying but I don't think my user context has access to the usual authentication fields by the time the protected route is first rendered. Here is my code below:

My Auth Context

import React, { createContext, useEffect, useState } from "react";
import { createUserWithEmailAndPassword, onAuthStateChanged, sendEmailVerification, signInWithEmailAndPassword, signOut, onIdTokenChanged } from 'firebase/auth';
import { auth } from '../../firebase-config';
import { createUserDocument } from "../../utils/userUtils";

const UserContext = createContext();

export const AuthContextProvider = ({ children }) => {
  const [user, setUser] = useState({});

  /** Create a new user */
  const createUser = async (email, password, role) => {
    try {

      await createUserWithEmailAndPassword(auth, email, password).then((userCredential) => {
        
        const currentUser = userCredential.user
        const userProps = { id: currentUser.uid, email: currentUser.email, role: role }
        createUserDocument(userProps)
        sendVerificationEmail(currentUser);

      });
    } catch (error) {
      console.error(error);
    }
  };

  /** Sends authenticated user a verification email */
  const sendVerificationEmail = (currentUser=user) => {
    const actionCodeSettings = {
      url: window.location.origin + '/home',
      handleCodeInApp: true,
    };

    sendEmailVerification(currentUser, actionCodeSettings).then(() => {
      console.log(`Verification email sent to user ${currentUser.email}`);
    })
  }

  /** Login a user into the tool */
  const login = async (email, password) => {
    const user = await signInWithEmailAndPassword(auth, email, password);
    return user
  };

  /** Logout a user from the tool */
  const logout = () => {
    return signOut(auth);
  };

  useEffect(() => {

    // Called when authorisation state changes
    const authState = onAuthStateChanged(auth, (currentUser) => {
      setUser(currentUser)
    });

    // Called when user token changes
    const tokenState = onIdTokenChanged(auth, (currentUser) => {
      // console.log("User Token Changed", currentUser)
    })

    return () => {
      authState();
      tokenState();
    };
  }, []);

  const contextValues = {
    createUser,
    sendVerificationEmail,
    login,
    logout,
    user
  };

  return (
    <UserContext.Provider value={contextValues}>
      {children}
    </UserContext.Provider>
  );
};

export const UserAuth = UserContext;

Here is my protected route:

import React, { useContext } from 'react'
import { UserAuth } from '../Context/AuthContext'
import { Navigate } from 'react-router-dom'

const ProtectedRoute = ({children}) => {
    const { user  } = useContext(UserAuth);
    if(!user){
        return <Navigate to='/' />
    }
    else if(user && !user.emailVerified){
        
           // THIS DOESNT WORK!
           return <Navigate to='/email'/>
    }
    
    return children
}

export default ProtectedRoute

I'll throw in my main App component too:

import './css/App.css';
import './css/style.css';
import React from 'react';
import { Routes, Route } from "react-router-dom";
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage';
import NoPage from './pages/NoPage';
import { AuthContextProvider } from './components/Context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import VerifyEmailPage from './pages/VerifyEmailPage';

class App extends React.PureComponent {

  render() {  
    return ( 
      <div className='App'>
        <div className='Blur'>
          <AuthContextProvider>
          <Routes>
              <Route path="/" element={<LoginPage/>}/>
              <Route path="/register" element={<RegisterPage/>}/>
              <Route path="/email" element={<VerifyEmailPage/>}/>
              <Route path="/home" element={<ProtectedRoute><HomePage/></ProtectedRoute>}/>
              <Route path="/*" element={<NoPage/>} /> 
            </Routes>
          </AuthContextProvider>
        </div>
      </div>
  )}
    
}

export default App

When I try to access any page it re-directs to my /email route. I understand why too. Its because user is an empty object. Which I believe is because the auth context hasn't had time to assign it the current user's values yet. Not sure if there is a particular way to do this or if its possible to begin with? Any help would be greatly appreciated. Thanks!

xw-liron
  • 11
  • 2

2 Answers2

0

If you are sure that user object is eventually being set and user has emailVerified property then you can add a conditional rendering check in the ProtectedRoute component.

const ProtectedRoute = ({ children }) => {
  const { user } = useContext(UserAuth)

  if (!user) {
    // User object is not available yet, render a loading state or redirect to a loading page if desired.
    // here we are just returning null.
    return null
  }

  if (!user.emailVerified) {
    // User is authenticated but their email is not verified
    return <Navigate to="/email" />
  }

  // User is authenticated and email is verified, render the protected content
  return children;
};

Nazrul Chowdhury
  • 1,483
  • 1
  • 5
  • 11
  • Am I missing something? You've hardly changed my own code that I posted in the question? I'm pretty sure that the user object is being set eventually, as I can access it in other components. Its just not set at the moment the protected route is mounted I believe. – xw-liron Jul 11 '23 at 08:18
  • oh I see what you were trying to say now. Apologies for the rash comment. I ended up using your suggestion and tweaking it so that it fit my use case. Thanks for the help! – xw-liron Jul 11 '23 at 09:19
  • you can render a loading spinner or loading state for the flashing. Though i don't know how you have implemented UserAuth context, but t it seems like you have added some redundant code. May be it's worth refactoring UserAuth context to eliminate the need to check for an empty object. Also, if you think my answer was acceptable, you can accept it! :) – Nazrul Chowdhury Jul 11 '23 at 10:05
0

I've updated my own component in order to achieve my desired functionality. I took the suggestions from Nazrul's answer and tweaked it to fit my use case. I now wait until my user object is definitely populated before checking if email is verified.

If the user is object is empty I return null. The update seems to have done the trick. Although there is a brief flash of white when redirecting to the /email page probably because its likely returning null for a little while before user object is populated.

Not sure if there is a way around that unfortunately, if anyone has any suggestions, feel free to let me know. If not, its something I can live with for now.

My updated component:

const ProtectedRoute = ({ children }) => {
    const { user } = useContext(UserAuth)

    // if user context is null, user isn't signed in
    if (!user) {
        return <Navigate to='/' />
    }

    // Check if user object is empty (i.e hasnt been set yet)
    const isUserEmpty = Object.keys(user).length === 0

    // If user object is populated
    if (!isUserEmpty) {

        // If user hasn't verified their email yet
        if (!user.emailVerified) {
            // User is authenticated but their email is not verified
            return <Navigate to="/email"/>
        }

        // If email is verified, return protected route
        return children
    }

    return null
}
xw-liron
  • 11
  • 2